Streaming & Backpressure Across Threads

A worker that parses a 2 GB log file can produce parsed records far faster than a page can render them. Nothing in postMessage notices. The queue grows inside the browser, the tab’s memory climbs past a gigabyte, and the heap snapshot shows nothing unusual — because the pending messages are not on the JavaScript heap at all.

This topic belongs to High-Performance Computation Patterns and covers the discipline that prevents it: making the producer’s rate depend on the consumer’s progress. Two mechanisms do that — transferable streams, where the platform handles it, and credit-based flow control, where you do — plus the instrumentation that tells you which one is failing.


The Failure: A Queue With No Ceiling

The naive shape is the one everybody writes first:

// worker.ts — a producer with no idea whether anyone is reading
for await (const batch of parseLogFile(file)) {
  self.postMessage({ kind: 'BATCH', batch }, [batch.buffer]);   // returns instantly, always
}
self.postMessage({ kind: 'END' });

postMessage never blocks and never rejects. Each call enqueues a message in the receiving agent’s task queue; the browser holds it in internal memory until the receiving event loop gets to it. If the page spends 14 ms rendering each batch and the worker produces one every 2 ms, the queue grows by six messages every 16 ms — around 22 MB per second at 64 kB per chunk.

The failure signature is worth memorising, because it is unlike an ordinary leak:

  • Memory grows in the browser process, not in the JS heap. A heap snapshot of both threads accounts for a few megabytes while the task manager shows a gigabyte.
  • The consumer becomes progressively less responsive, because its event loop is saturated by a backlog it cannot drain.
  • Cancellation does nothing. Terminating the worker stops production but the queued messages have already been handed to the consumer.
  • It scales with hardware in the wrong direction. A faster machine produces faster, so the gap widens.
Queue depth over time with and without flow control Two lines over ten seconds. Without flow control, the number of undelivered chunks climbs steadily from zero to about six hundred, representing roughly forty megabytes of browser-held memory, and it never recovers because the producer is faster than the consumer. With a credit limit of eight, the depth rises to eight almost immediately and then oscillates between six and eight for the whole run, so memory is bounded by the credit limit times the chunk size regardless of how long the job lasts. Undelivered chunks while a worker parses a 2 GB file no flow control — unbounded credit limit 8 — bounded by design 0 300 600 chunks 0 s 5 s 10 s Peak memory = queue depth × chunk size. One of these lines has a ceiling; the other has a crash.
The olive line is not slower — both pipelines finish in the same wall clock, because throughput is set by the consumer either way. Only the memory differs.

Prerequisites

  • A consumer whose per-chunk cost you have measured. Flow control is calibrated against it; without a number, any credit limit is a guess.
  • Chunked data. A single 500 MB blob is not a stream — it is one message, and it belongs in Transferable Objects & Zero-Copy.
  • A module worker, so ReadableStream and TextDecoderStream are available with the same spellings on both sides.
  • An end-of-stream marker in the protocol, explicit and unambiguous, even if you also use streams elsewhere.
  • A cancellation path. Streams give you one; a hand-written protocol needs a control message the producer actually checks.

Step 1 — Transfer the Stream, Not the Chunks

The platform already solved this problem, and the solution is transferable. A ReadableStream, WritableStream or TransformStream can appear in a transfer list, after which the object belongs to the receiving agent and the browser tunnels chunks between threads with backpressure intact.

// page.ts — hand the file's byte stream to the worker
const worker = new Worker(new URL('./parse.worker.ts', import.meta.url), { type: 'module' });
const stream = file.stream();                        // ReadableStream<Uint8Array>

worker.postMessage({ kind: 'PARSE', stream }, [stream]);   // the stream itself moves
// parse.worker.ts — read at whatever pace the parser can manage
self.addEventListener('message', async (event: MessageEvent) => {
  const { stream } = event.data as { stream: ReadableStream<Uint8Array> };
  const reader = stream.pipeThrough(new TextDecoderStream()).getReader();

  for (;;) {
    const { value, done } = await reader.read();     // pulls only when ready
    if (done) break;
    parseChunk(value);
  }
  self.postMessage({ kind: 'END' });
});

The await reader.read() is the whole mechanism. The reader pulls one chunk at a time; the source produces the next only when asked. If the parser slows down, the file reader slows down with it, and nothing accumulates anywhere — the queue is bounded by the stream’s own high-water mark, which defaults to one chunk for byte streams.

Transferring a stream costs nothing per chunk

The transfer itself is O(1) — a pointer move at postMessage time. Chunks that subsequently cross the boundary are still structured-cloned or transferred individually by the browser, so chunk size still matters, but the flow-control machinery adds no measurable per-chunk overhead of its own.


Step 2 — Credit-Based Flow Control Where Streams Do Not Fit

Streams are the right answer when the data is stream-shaped and the engines support them. Two situations call for something else: a protocol where chunks are results rather than bytes (parsed record batches, rendered tiles, search hits), and support floors that predate transferable streams.

The mechanism is the one TCP uses: the consumer grants credits, the producer spends them.

// worker.ts — the producer half
let credits = 0;
let pending: (() => void) | null = null;

self.addEventListener('message', (event: MessageEvent) => {
  if (event.data.kind === 'CREDIT') {
    credits += event.data.n as number;
    pending?.();                       // wake the paused producer
    pending = null;
  }
});

async function awaitCredit(): Promise<void> {
  if (credits > 0) return;
  await new Promise<void>((resolve) => { pending = resolve; });
}

export async function produce(source: AsyncIterable<Float64Array>): Promise<void> {
  for await (const batch of source) {
    await awaitCredit();               // the only line that matters
    credits--;
    self.postMessage({ kind: 'BATCH', batch }, [batch.buffer]);
  }
  self.postMessage({ kind: 'END' });
}
// page.ts — the consumer half
const CREDIT_LIMIT = 8;
worker.postMessage({ kind: 'CREDIT', n: CREDIT_LIMIT });     // prime the pipeline

worker.addEventListener('message', async (event: MessageEvent) => {
  if (event.data.kind !== 'BATCH') return;
  await render(event.data.batch);                            // the slow part
  worker.postMessage({ kind: 'CREDIT', n: 1 });              // return exactly one
});

Peak memory is now CREDIT_LIMIT × chunkSize, chosen rather than discovered. Eight is a good starting point: enough in flight that the producer is never idle waiting for a round trip, few enough that a 1 MB chunk size caps the queue at 8 MB.

Return the credit after the work, not on arrival

Sending the credit at the top of the handler restores the unbounded behaviour with extra steps: the producer refills as fast as messages arrive, regardless of whether anything was rendered. The credit must represent capacity freed, which means it is returned after the consumer has finished with the chunk and released its memory.

The credit loop between producer and consumer A cycle across two lanes. The consumer grants an initial budget of eight credits. The producer checks its credit balance before each chunk: with credits available it decrements the balance and posts a chunk; with none it parks on a promise and does no work at all. The consumer receives a chunk, renders it, releases the memory, and only then returns exactly one credit, which wakes the parked producer. A note records that peak memory equals the credit limit times the chunk size, and that returning the credit before rendering would restore unbounded growth. Credits are capacity, and capacity is only freed after the work Producer (worker) credits > 0 → decrement, post a chunk credits = 0 → park on a promise parked means no CPU spent at all Consumer (page) receive → render → release memory then return exactly one credit order is the whole guarantee chunk credit peak memory = credit limit × chunk size 8 credits × 1 MB chunks = 8 MB in flight, whether the job lasts one second or one hour the mistake that undoes it returning the credit on arrival instead of after rendering — the producer refills at message rate
Credits model capacity, not acknowledgement. The distinction sounds pedantic until the ordering is wrong and the ceiling disappears.

Step 3 — Size the Chunks

Chunk size trades per-message overhead against per-message latency, and the useful range is narrower than people expect.

Chunk size Messages for 500 MB Transport overhead Risk
4 kB 128,000 25–60 s of hops — dominates unusable
64 kB 8,000 1.6–4 s across the job good for record batches
256 kB 2,000 0.4–1 s the usual sweet spot
1 MB 500 0.1–0.25 s good for binary payloads
16 MB 32 negligible one clone can block 20+ ms

Two rules generalise from it. Keep transport under 10% of total job time — measure one chunk end to end and multiply. And keep a single chunk’s handling under 8 ms on the consumer, so processing one never costs a dropped frame. Where the consumer renders, that second rule usually binds first and points at 64–256 kB.

Always transfer binary chunks rather than cloning them: postMessage(chunk, [chunk.buffer]) moves the bytes and removes the copy from both sides of the hop.


Step 4 — Adapt the Window Instead of Guessing It

A fixed credit limit is a compromise between two failure modes: too low and the producer idles for a round trip after every chunk; too high and peak memory is larger than it needs to be. When chunk cost varies — records that are sometimes 40 kB and sometimes 4 MB — a fixed count is wrong in both directions at different moments.

Two adjustments fix most of it without inventing a congestion-control algorithm.

Grant credits in bytes, not chunks. The consumer tracks how many bytes it has released rather than how many messages it handled, so a window expressed as “16 MB in flight” holds regardless of chunk size:

// consumer: a byte-denominated window
const WINDOW_BYTES = 16 * 1024 * 1024;
let outstanding = 0;

worker.addEventListener('message', async (event: MessageEvent) => {
  const { batch } = event.data as { batch: Float64Array };
  outstanding += batch.byteLength;
  await render(batch);
  outstanding -= batch.byteLength;
  worker.postMessage({ kind: 'CREDIT', bytes: batch.byteLength });
});

Keep two chunks in flight, minimum. The producer should always have permission for the next chunk while the consumer works on the current one; otherwise every chunk costs a full round trip of dead time. A floor of two credits (or two chunks’ worth of bytes) is the difference between a pipeline and a ping-pong game, and it costs one extra chunk of memory.

Anything beyond that — measuring consumer latency and growing the window until throughput stops improving — is worth doing only when the consumer’s cost varies by an order of magnitude within one job. For most pipelines, a byte window sized at 8–32 MB with a two-chunk floor is within a few percent of optimal and has no tuning surface to get wrong later.


Advanced: A Pipeline Across a Pool

Streaming and parallelism interact awkwardly, because a pool destroys ordering. Three shapes are worth knowing.

Serial stages, one worker each. Decode in worker A, transform in worker B, aggregate on the page. Each pair of stages is its own flow-controlled link, and the slowest stage sets the rate for everything upstream — which is exactly what backpressure is for. Transferable streams shine here: readable ends can be transferred from worker to worker directly through a MessageChannel, without the page relaying chunks.

Fan-out to N workers, unordered. Suitable when results carry their own identity — tiles, records with keys, search hits. The dispatcher hands chunk i to whichever worker is idle, and the consumer reassembles by key. Credits are per-worker, so the total in flight is N × limit; size the per-worker limit accordingly, or the memory ceiling multiplies quietly with hardwareConcurrency.

Fan-out with a reorder buffer. Required when output order must match input order. The consumer holds completed-but-early results until their predecessors arrive, which means the buffer can grow to the spread between the fastest and slowest worker. Cap it explicitly and stop granting credits when it fills, or the reorder buffer becomes the unbounded queue you removed from the transport.

The pool mechanics — sizing, idle tracking, dispatch — are covered in Worker Pool Management; what changes in a streaming context is only that each worker needs its own credit accounting rather than one global counter.


Data-Transfer Strategy for Streams

Traffic Mechanism Backpressure
Bytes from a file, network or encoder transferable ReadableStream built in
Parsed record batches (typed arrays) postMessage + transfer + credits hand-written
Rendered tiles or ImageBitmaps postMessage + transfer + credits hand-written
Continuous samples at a fixed rate SharedArrayBuffer ring buffer slot availability
Occasional progress on one job callback or single message not needed

The ring-buffer row is the one to reach for when the rate is fixed and the latency budget is sub-millisecond — audio, sensors, simulation frames. There the queue is a fixed allocation by construction and the coordination is done with Atomics, as in Building a Lock-Free Ring Buffer with Atomics.

Shared-memory pipelines need isolation headers

A SharedArrayBuffer ring buffer only exists on a cross-origin isolated document: serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and make every cross-origin subresource CORP- or CORS-eligible. Streams and credit protocols need no such headers, which is often the deciding factor.


Verification & Measurement

Export queue depth as a number. With credits, the producer already knows it: sent - acknowledged. Log the maximum per job, and alert on any job whose depth reaches the credit limit for more than a second — that is a consumer that has stopped consuming.

// producer: two counters and one derived metric
let sent = 0, acked = 0;
// …on post: sent++      …on credit: acked++
const depth = () => sent - acked;
performance.measure(`stream-depth-${depth()}`, { start: performance.now(), duration: 0 });

Watch the receiving event loop, not the sending one. A saturated consumer shows long tasks back to back with no idle gaps; the profiling technique is in Profiling Worker CPU Usage with the Chrome Performance Tab. A producer-bound pipeline looks the opposite: an idle consumer waiting between chunks.

Reproduce a slow consumer deliberately. Flow control is only exercised when the consumer lags, which on a development machine it rarely does. Add a test mode that inserts an artificial delay per chunk — await new Promise(r => setTimeout(r, 40)) — and confirm three things: peak memory stays at the designed ceiling, the producer parks rather than spins, and cancelling mid-job actually stops production. A pipeline that has never been run against a slow consumer has never had its backpressure tested.

Compare wall clock against the theoretical floor. Total time should approach chunks × consumerCostPerChunk. Materially longer means transport overhead — chunks too small — or a credit limit so low the producer stalls waiting for round trips.


Choosing a transport by message rate and payload shape Four traffic profiles matched to a transport. Occasional request and reply traffic needs no flow control at all. Bytes from a file or network fit a transferable stream, where the platform carries backpressure. Discrete result batches need a credit protocol, because the traffic is not stream-shaped. Fixed-rate continuous samples fit a shared-memory ring buffer, where the queue is a fixed allocation by construction. Matching traffic to transport a few messages per job — request and reply no flow control needed; a timeout is the only safeguard bytes from a file, socket or encoder transferable ReadableStream — backpressure comes from the platform discrete result batches, tiles or hits credit protocol — the consumer grants capacity, the producer spends it continuous samples at a fixed rate SharedArrayBuffer ring buffer — the queue is a fixed allocation The wrong row is not slower — it is unbounded, which is a different class of failure.
Rate and shape decide the transport; the credit protocol exists for the third row, where the platform offers nothing.

Failure Modes & Error Handling

Two consumers on one channel. Adding a second message listener that also handles chunks doubles the work and halves the credits returned per chunk, because only one listener owns the accounting. Keep one owner per channel and fan out inside it.

A lost end marker hangs the job forever. If END is posted but the consumer had already unsubscribed, or an error path skips it, the consumer waits indefinitely. Make completion explicit and idempotent, and add a watchdog timeout for jobs whose duration you can bound.

Cancellation that does not reach the producer. With streams, reader.cancel() propagates to the source. With a credit protocol, nothing propagates unless you send a CANCEL message and the producer checks it between chunks — a tight loop that never yields will not see it. The abortable-task patterns in Cancelling Worker Tasks with AbortSignal apply directly.

Errors that vanish mid-stream. A throw inside the producer’s loop rejects a promise nobody is awaiting. Catch it, post an ERROR message with a serialised cause, and have the consumer reject its own completion promise — see Structured Error Serialization Across Threads.

A window sized from a fast machine. The consumer’s per-chunk cost is several times higher on a low-end phone, which changes the ideal window. Derive it at runtime from measured consumer latency rather than hard-coding a constant tuned on a laptop.

Reusing a transferred chunk. Once posted with a transfer list the buffer is detached in the producer; a producer that recycles a scratch buffer across iterations will find it empty on the second pass. Allocate per chunk, or use a pool of buffers the consumer returns.

A producer that never yields. Flow control assumes the producer’s loop reaches an await between chunks; a synchronous loop that computes for 400 ms cannot see a credit message, a cancel message, or anything else, because the worker’s event loop never runs. Any producer whose per-chunk work is long must yield explicitly — the chunking techniques in Breaking Long Tasks into Yielding Chunks apply on the worker thread exactly as they do on the main one.

Credit leaks. A consumer that throws while rendering and never returns the credit permanently reduces the window; after N failures the pipeline is deadlocked at zero credits. Return credits in a finally.


Browser Compatibility

Capability Chrome Firefox Safari Edge
ReadableStream in workers 43+ 65+ 10.1+ 79+
Transferable streams 87+ 103+ 16.4+ 87+
TextDecoderStream 71+ 105+ 14.1+ 79+
Blob.stream() 76+ 69+ 14.1+ 79+
SharedArrayBuffer (isolated) 92+ 79+ 15.2+ 92+

Transferable streams are the newest of these; a support floor below Safari 16.4 is the main reason to write a credit protocol instead. Async iteration over a ReadableStream (for await (const chunk of stream)) landed later still and is not yet universal, so prefer an explicit getReader() loop in portable code.

Frequently Asked Questions

Does postMessage apply backpressure automatically?
No. postMessage returns immediately and the runtime queues the message in the receiving agent regardless of whether that agent is keeping up. A worker that posts 400 chunks per second to a page that renders 60 of them builds an unbounded queue inside the browser, and the only symptom until the tab dies is growing memory that no heap snapshot attributes to your objects — the queue is not on the JavaScript heap.
Can I transfer a ReadableStream to a worker?
Yes, in every current engine: worker.postMessage({ stream }, [stream]). Transferable streams move the stream itself, and the browser propagates backpressure, cancellation and errors across the thread boundary for you. That makes them the correct default; credit-based flow control is the fallback for older Safari and for protocols that are not stream-shaped.
What chunk size should I use between threads?
64 kB to 1 MB of payload per message for binary data. Smaller than about 16 kB and per-message overhead (0.1–0.5 ms each way) dominates; larger than a few megabytes and a single chunk’s structured clone becomes a long task on the receiving thread. Time one chunk end to end and pick the size where the transport is under 10% of total time.
How do I know the consumer is falling behind before the tab crashes?
Instrument the protocol, not the browser: have the consumer acknowledge chunks and have the producer track sent - acknowledged. That number is queue depth. A healthy pipeline oscillates around the credit limit; a failing one climbs monotonically, and you can log or throttle long before memory becomes visible in the task manager.

See also