HomeJournalThis post

SharedArrayBuffer Ring Buffer for Audio

Implement a browser audio ring buffer with cross-origin isolation, single-producer ownership, Atomics indices, wraparound, underrun policy, telemetry, and tests.

JP
JP Casabianca
UI/UX designer and full-stack engineer · Bogotá

A SharedArrayBuffer ring buffer lets a producer stream audio frames to an AudioWorklet without copying each block or blocking the rendering thread. This tutorial defines single-producer ownership, atomic cursors, wraparound, underrun policy, cross-origin isolation, telemetry, and a runnable buffer invariant.

SharedArrayBuffer ring buffer has two owners

Use the simplest concurrency model that serves the product: one producer writes audio frames and one consumer reads them. This single-producer single-consumer boundary is usually called SPSC. The producer may be a Worker decoding or synthesizing audio; the consumer is the AudioWorklet processor. A second writer or reader invalidates the cursor rules and needs another design.

Allocate one shared control region and one shared sample region. The control region holds monotonically interpreted read and write indices plus optional counters; the sample region holds interleaved or planar frames under a documented layout. This AudioWorklet buffer never uses the final empty slot, which distinguishes full from empty without another shared flag.

Draw ownership on the circular diagram. Only the producer writes samples at the current write cursor and advances that cursor. Only the consumer reads samples at the current read cursor and advances it. Both may load the other cursor atomically. This narrow rule is the reason the algorithm can avoid locks; “lock-free” is not a promise that arbitrary code may mutate shared memory.

Atomic cursors share a circular audio runwayA producer advances the write cursor around shared frames while the AudioWorklet advances the read cursor and leaves one slot empty.WRITEREADsharedframes
  • Write: producer-owned cursor
  • Read: worklet-owned cursor
  • Frames: published audio samples
  • Gap: one empty slot distinguishes full
Figure 1: Single-producer and single-consumer ownership makes cursor publication sufficient.

Establish cross-origin isolation first

Browsers restrict SharedArrayBuffer because shared high-resolution memory can contribute to side-channel attacks. Serve documents with a compatible Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy, audit every subresource, and verify crossOriginIsolated at runtime. A missing header or incompatible third-party embed should choose a documented fallback.

The ECMAScript Atomics section defines atomic operations, the Web Audio specification defines real-time processing constraints, and MDN SharedArrayBuffer guidance summarizes deployment requirements. Treat header policy as part of the audio architecture, not a last-minute server toggle.

Fallback options include transferable blocks through MessagePort, a larger queued buffer, or disabling the low-latency feature. Measure their latency and allocation cost honestly. The SharedArrayBuffer ring buffer can be an enhancement when the page cannot isolate, as long as the UI explains reduced capability rather than failing silently.

Write the ownership rule beside the memory layout: the producer alone advances write, the worklet alone advances read, and neither repairs the other cursor. Keep that invariant visible in the fixture.

Lay out indices and frames explicitly

Use an Int32Array for cursors because Atomics operates on supported integer typed arrays. Store audio samples in a Float32Array over a separate shared region or a nonoverlapping offset. Document byte offsets, channel order, frame stride, capacity, and version so both producer and worklet interpret identical memory.

Capacity should be a frame count, not a byte count casually divided in two places. For stereo interleaved audio, one logical frame contains two adjacent samples. Cursor arithmetic advances by frames, while sample addressing multiplies by channel count. This distinction prevents wraparound from splitting a stereo pair.

The runnable fixture uses scalar samples to make cursor invariants obvious. A production SharedArrayBuffer ring buffer needs equivalent tests for frame stride, multiple channels, exact full capacity, one-slot-empty policy, wrap at every boundary, and constructor validation. Reject sizes too small to distinguish states and allocations whose byte layout does not match the declared schema.

The Node fixture allocates shared indices and samples, fills the usable capacity, proves full detection, consumes one slot, wraps the writer, and preserves the unread order.

Runnable artifact — shared-ring-buffer.test.mjs

import assert from "node:assert/strict";
const state=new Int32Array(new SharedArrayBuffer(8)),data=new Float32Array(new SharedArrayBuffer(4*4));const capacity=data.length;const write=value=>{const w=Atomics.load(state,0),r=Atomics.load(state,1),next=(w+1)%capacity;if(next===r)return false;data[w]=value;Atomics.store(state,0,next);return true};const read=()=>{const w=Atomics.load(state,0),r=Atomics.load(state,1);if(r===w)return null;const value=data[r];Atomics.store(state,1,(r+1)%capacity);return value};
assert.equal(write(1),true);assert.equal(write(2),true);assert.equal(write(3),true);assert.equal(write(4),false);assert.equal(read(),1);assert.equal(write(4),true);assert.deepEqual([read(),read(),read(),read()],[2,3,4,null]);
console.log("PASS: ring buffer wraps without overwriting unread frames");

Run node shared-ring-buffer.test.mjs. Expected receipt: PASS: ring buffer wraps without overwriting unread frames.

Publish samples before the write cursor

The producer loads the read cursor, calculates available space, writes complete frames into its owned slots, then atomically stores the new write cursor. The cursor publication tells the consumer which samples are ready. Never advance the cursor first; the worklet might observe readiness and read partially written audio.

The consumer loads the write cursor, calculates available frames, copies what it needs into the output quantum, then atomically stores the new read cursor. It must not wait for more data. If frames are missing, the real-time policy supplies silence, holds a carefully chosen last value, or uses another bounded concealment strategy.

Atomics memory ordering in JavaScript is sequentially consistent, which simplifies this teaching implementation. The SharedArrayBuffer ring buffer still benefits from comments stating the publication relationship. Future maintainers should understand that sample writes happen before the cursor store and sample reads happen before the read-cursor store, rather than treating atomic calls as ceremonial decorations.

Calculate wraparound without copying the ring

For an index i and capacity N, the next slot is (i + 1) % N. Bulk writes and reads split into at most two contiguous spans: from cursor to end, then from zero for the remainder. Use typed-array views or explicit loops whose allocation behavior is known, and test both exact-end and cross-end cases.

The table shows empty, partially filled, full, and wrapped states with their usable frames. Reserve one slot consistently. A common bug computes free space as N - used even though the representation can store only N - 1; that overwrite destroys the oldest unread frame and may sound like a random click.

Keep the ring size modest and related to a latency budget. Larger buffers absorb scheduling jitter but increase delay. A SharedArrayBuffer ring buffer should expose target fill, low-water mark, and high-water mark in frames and milliseconds. Tune those values from device traces rather than choosing a power of two only because bit masks look elegant.

StateReadWriteUsable
Empty220
Partial253
Full21N−1
Wrapped62Across zero
Figure 2: One reserved slot makes empty and full states unambiguous.

Make underrun and overrun product decisions

An underrun means the consumer needs frames that do not exist. Write zeros for the missing region, increment an atomic counter, and keep the worklet moving. An overrun means the producer has more frames than free slots. Choose whether to backpressure upstream, drop newest data, drop oldest data under a separate discontinuity protocol, or reset the stream.

Audio semantics determine the answer. Live conversation may prefer a bounded drop that preserves current timing; music playback may prefer buffering before start; a synthesizer may generate directly at render cadence. Never let the model or UI thread improvise this policy during pressure.

For each event, record available frames, requested frames, cursor positions, stream time, recovery action, and device context outside the real-time callback when possible. The SharedArrayBuffer ring buffer should not log strings or allocate telemetry objects inside process(). Use atomic counters and sample them from a non-real-time thread.

Keep counters outside the sample path and aggregate them slowly, preventing observability from adding allocation, locks, or message pressure to the real-time callback. Sample reporting belongs on the control thread.

Connect flow control to the rest of the app

The producer needs a target-fill controller. Pause decode or network reads near the high-water mark, resume below a lower threshold, and avoid rapid oscillation. If upstream cannot pause, define a bounded queue and loss policy. Ring occupancy is a backpressure signal, not merely a debugging chart.

Related Journal pieces separate concerns: AudioWorklet creative audio covers render deadlines, backpressure and flow control models capacity, scheduler.yield protects main-thread responsiveness, and SSE vs WebSockets compares streaming transports.

Plot occupancy, underruns, overruns, producer work time, worklet duration, and end-to-end latency on one timeline. A SharedArrayBuffer ring buffer can stay logically correct while an upstream decoder starves it every second. The system trace should reveal whether the pressure originates at network, decode, generation, scheduling, or consumption. Add device sample rate and base latency to each session so different hardware is not flattened into one misleading percentile.

Compare deliberate silence with underrun silence in telemetry and UI copy so a valid pause is never diagnosed as a broken audio pipeline. Give each state its own receipt.

  1. 1Observe

    Load the opposite cursor

  2. 2Transfer

    Read or write owned frames

  3. 3Publish

    Atomically advance cursor

  4. 4Measure

    Sample pressure counters

Figure 3: The real-time path transfers frames and publishes only after completion.

Release with a virtual-clock audio stress test

Test empty reads, exact writes, full rejection, wraparound, multi-channel frames, producer bursts, consumer bursts, cancellation, reset, worker restart, worklet recreation, and cross-origin-isolation fallback. Use a deterministic scheduler for logic tests, then run browser stress with throttled CPU and realistic render quantum timing.

The release gate stops on overwritten unread frames, reordered samples, cursor races, blocking or allocation spikes in the worklet, unbounded producer queues, missing fallback, or unexplained underrun clusters. Listen to generated ramps and impulses as well as music; simple signals make discontinuities easier to locate.

Archive memory layout, ownership rules, buffer capacity, watermarks, underrun policy, header configuration, browser matrix, counters, and test seeds. Revisit the SharedArrayBuffer ring buffer when channel layout, sample rate, latency target, or isolation policy changes. Real-time correctness is a continuing agreement between memory order and product timing, not a one-time microbenchmark. Keep a long-running soak test that advances cursors across many integer wraps, changes producer burst size, and asserts a generated sample ramp remains continuous. Short unit cases prove the boundary; the soak catches arithmetic assumptions that only appear after sustained playback.

Keep the audio contract small and observable

The ring buffer is small because its contract is narrow: one writer, one reader, complete frames, and atomic publication. Preserve that simplicity, then spend engineering effort on the harder product questions around isolation, pressure, underruns, and observable latency. A diagram of the cursor contract should live beside the implementation so optimization never widens ownership invisibly. Compare every optimization against the sample-ramp oracle and the real-time allocation trace, not only against average throughput. Keep the fallback path in the same stress suite so cross-origin policy changes degrade capability explicitly instead of turning audio into silence.