Modern Web

Designing a Low-Latency Real-Time Audio Mixer with AudioWorklet and Web Audio API

Master browser digital signal processing (DSP). Code a low-latency multitrack audio mixer using AudioWorklet to bypass JavaScript main-thread audio blocks.

Sachin Sharma
Sachin SharmaCreator
Jun 4, 2026
5 min read
Designing a Low-Latency Real-Time Audio Mixer with AudioWorklet and Web Audio API
Featured Resource
Quick Overview

Master browser digital signal processing (DSP). Code a low-latency multitrack audio mixer using AudioWorklet to bypass JavaScript main-thread audio blocks.

Designing a Low-Latency Real-Time Audio Mixer with AudioWorklet and Web Audio API

Traditional web audio applications depend on high-level Web Audio API nodes (like GainNode or BiquadFilterNode) routed together in a logical graph. While this is sufficient for simple synthesizers, it falls flat when building complex, professional audio workstations (DAWs) or real-time gaming mixers.

When mixing 16+ tracks of high-definition raw audio, applying real-time effects, and keeping everything in sample-perfect synchronization, the main browser thread quickly becomes a bottleneck. Any garbage collection sweep or UI layout reflow will trigger audible audio glitches (clicks and pops).

To achieve studio-grade, zero-latency audio processing, you must leverage AudioWorklet.

In this guide, we'll design and build a multi-channel real-time audio mixer featuring custom volume faders and panning nodes running entirely inside a dedicated, low-latency audio rendering thread.


⚡ 1. The AudioWorklet Architecture

Web Audio operates two threads:

  1. 2.
    Main Thread (JS Application): Handles UI rendering, user interaction, and orchestrates the audio graph setup.
  2. 4.
    Audio Rendering Thread (Native OS): Runs the audio hardware loop. AudioWorklet lets us inject custom JavaScript/WebAssembly code directly into this thread, running at the highest OS-level priority.

To prevent communication blocks, the main thread and the AudioWorklet communicate asynchronously via MessagePorts or SharedArrayBuffers for lock-free memory access.

[Main Thread UI (Faders)] ──(MessagePort Parameter)──> [AudioWorkletNode (JS)]
                                                              │
                                                   (Raw Audio Stream Arrays)
                                                              ▼
                                                [AudioWorkletProcessor (Core)]
                                                   - Custom DSP Mix Loops
                                                   - Float32 Sample Mixing
                                                              │
[User Speakers] <─────────────────────────────────────────────┘

🏗️ 2. Coding the AudioWorkletProcessor

The processor runs inside the audio thread. It receives arrays of input audio channels, processes them (applying gains, panning, mixing), and writes the resulting samples directly to the output array.

Web Audio processes audio in blocks of 128 samples (approx. 2.9ms at 44.1kHz).

Let's write our custom MixerProcessor:

javascript
// mixer-processor.js class MixerProcessor extends AudioWorkletProcessor { static get parameterDescriptors() { return [ { name: 'gainTrack1', defaultValue: 0.8, minValue: 0, maxValue: 1.0 }, { name: 'panTrack1', defaultValue: 0.0, minValue: -1.0, maxValue: 1.0 }, { name: 'gainTrack2', defaultValue: 0.8, minValue: 0, maxValue: 1.0 }, { name: 'panTrack2', defaultValue: 0.0, minValue: -1.0, maxValue: 1.0 } ]; } process(inputs, outputs, parameters) { const output = outputs[0]; const leftChannelOut = output[0]; const rightChannelOut = output[1]; // Clear output buffers leftChannelOut.fill(0); rightChannelOut.fill(0); const trackCount = inputs.length; // Loop through 128 samples for (let sample = 0; sample < 128; sample++) { let mixedLeft = 0; let mixedRight = 0; for (let t = 0; t < trackCount; t++) { const input = inputs[t]; if (!input || input.length === 0) continue; const inputChannel = input[0]; // Mono input channel const sampleValue = inputChannel[sample] || 0; // Retrieve dynamic parameters (handles parameter automation/ramping!) const trackGain = parameters[`gainTrack\${t + 1}`]?.length > 1 ? parameters[`gainTrack\${t + 1}`][sample] : (parameters[`gainTrack\${t + 1}`]?.[0] ?? 0.8); const trackPan = parameters[`panTrack\${t + 1}`]?.length > 1 ? parameters[`panTrack\${t + 1}`][sample] : (parameters[`panTrack\${t + 1}`]?.[0] ?? 0.0); // Constant-power panning calculations const panAngle = (trackPan + 1) * Math.PI / 4; const leftGain = Math.cos(panAngle) * trackGain; const rightGain = Math.sin(panAngle) * trackGain; mixedLeft += sampleValue * leftGain; mixedRight += sampleValue * rightGain; } // Hard clipping limiter to prevent digital distortion leftChannelOut[sample] = Math.max(-1.0, Math.min(1.0, mixedLeft)); rightChannelOut[sample] = Math.max(-1.0, Math.min(1.0, mixedRight)); } return true; // Keep the worklet alive } } registerProcessor('mixer-processor', MixerProcessor);

💻 3. Loading the AudioWorklet Node

Let's register the audio processor module from our main JavaScript file, and initialize the mixer layout.

javascript
let audioCtx; let mixerNode; async function setupMixer() { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); // 1. Load the custom worklet processor file await audioCtx.audioWorklet.addModule('/js/mixer-processor.js'); // 2. Instantiate the Mixer Node (supports 2 tracks, 2 output channels) mixerNode = new AudioWorkletNode(audioCtx, 'mixer-processor', { numberOfInputs: 2, numberOfOutputs: 1, outputChannelCount: [2] // Stereo output }); // 3. Connect sources (e.g., dynamic audio decoders or microphones) const track1Source = await loadAudioTrack('/audio/drums.mp3'); const track2Source = await loadAudioTrack('/audio/synth.mp3'); track1Source.connect(mixerNode, 0, 0); // Connect drums to Input 0 track2Source.connect(mixerNode, 0, 1); // Connect synth to Input 1 // Connect the mixer to the speakers mixerNode.connect(audioCtx.destination); track1Source.start(); track2Source.start(); } async function loadAudioTrack(url) { const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); const bufferSource = audioCtx.createBufferSource(); bufferSource.buffer = audioBuffer; bufferSource.loop = true; return bufferSource; }

🚀 4. Adjusting Faders in Real-Time

To change volume or panning from UI sliders, we manipulate the parameters directly on the AudioWorkletNode instance. This updates the audio thread smoothly without causing clicks:

javascript
function setTrackVolume(trackIndex, volume) { // Volume ranges from 0.0 (mute) to 1.0 (full) const parameterName = `gainTrack\${trackIndex}`; const gainParam = mixerNode.parameters.get(parameterName); if (gainParam) { // Schedule a smooth exponential volume ramp to prevent abrupt clicks! gainParam.exponentialRampToValueAtTime(volume, audioCtx.currentTime + 0.05); } } function setTrackPanning(trackIndex, panValue) { // Panning ranges from -1.0 (hard left) to 1.0 (hard right) const parameterName = `panTrack\${trackIndex}`; const panParam = mixerNode.parameters.get(parameterName); if (panParam) { panParam.setValueAtTime(panValue, audioCtx.currentTime); } }

🏁 5. Conclusion

AudioWorklets are the core foundation of modern web-based audio applications. Moving your audio routing, DSP arithmetic, and constant-power stereo panning out of JavaScript's main loop and straight to OS-level rendering threads allows you to build highly responsive, zero-latency, multi-channel mixing boards capable of executing smooth audio processing directly in browser clients.

Sachin Sharma

Sachin Sharma

Software Developer

Building digital experiences at the intersection of design and code. Sharing weekly insights on engineering, productivity, and the future of tech.