Implementing Backpressure with Message Credits

When the data is not stream-shaped — record batches, rendered tiles, search hits — the platform will not throttle the producer for you. A credit window is the smallest mechanism that will, and it fits in about sixty lines shared by both threads.

This is the hand-written path from Streaming & Backpressure Across Threads, part of High-Performance Computation Patterns. The design goal is a hard ceiling on in-flight memory that does not depend on how fast either thread happens to run.


The Channel, Both Halves

One shared module, imported by the page and the worker, so the message shapes cannot drift.

// credit-channel.ts — shared by both threads
export type ChannelMessage<T> =
  | { kind: 'CHUNK'; seq: number; payload: T }
  | { kind: 'CREDIT'; n: number }
  | { kind: 'END'; count: number }
  | { kind: 'ERROR'; message: string }
  | { kind: 'CANCEL' };

export class CreditProducer<T> {
  #credits = 0;
  #wake: (() => void) | null = null;
  #cancelled = false;
  #seq = 0;

  constructor(private post: (m: ChannelMessage<T>, transfer?: Transferable[]) => void) {}

  /** Called from the message listener on the producer's side. */
  handle(message: ChannelMessage<T>): void {
    if (message.kind === 'CREDIT') { this.#credits += message.n; this.#wake?.(); this.#wake = null; }
    if (message.kind === 'CANCEL') { this.#cancelled = true; this.#wake?.(); this.#wake = null; }
  }

  async send(payload: T, transfer?: Transferable[]): Promise<boolean> {
    if (this.#credits === 0) await new Promise<void>((r) => { this.#wake = r; });
    if (this.#cancelled) return false;          // stop the loop cooperatively
    this.#credits--;
    this.post({ kind: 'CHUNK', seq: this.#seq++, payload }, transfer);
    return true;
  }

  end(): void { this.post({ kind: 'END', count: this.#seq }); }
}
// credit-consumer.ts — the other half
export class CreditConsumer<T> {
  #outstanding = 0;

  constructor(
    private post: (m: ChannelMessage<T>) => void,
    private onChunk: (payload: T, seq: number) => Promise<void> | void,
    private window = 4,
  ) {
    this.post({ kind: 'CREDIT', n: this.window });   // prime the pipeline
  }

  async handle(message: ChannelMessage<T>): Promise<void> {
    if (message.kind !== 'CHUNK') return;
    this.#outstanding++;
    try {
      await this.onChunk(message.payload, message.seq);
    } finally {
      this.#outstanding--;
      this.post({ kind: 'CREDIT', n: 1 });            // ALWAYS, even on failure
    }
  }

  get depth(): number { return this.#outstanding; }
}

The producer’s send is the only place that can block, and it blocks by parking on a promise rather than spinning — a parked worker uses no CPU, so a stalled consumer costs nothing but latency.

Producer state machine under a credit window A four-state machine. Producing is the initial state: with credits available the producer decrements the balance, posts a chunk and returns to producing. With no credits it moves to parked, where it consumes no CPU and holds no chunk in memory beyond the one it is about to send. A credit message returns it to producing. A cancel message from either state moves it to stopping, where it abandons the loop and posts nothing further. When the source is exhausted the producer moves to ended and posts the end marker with a final count so the consumer can verify nothing was lost. Four states, one of which does nothing at all producing credits > 0 → decrement, post a chunk, loop parked awaiting a promise — zero CPU, zero growth stopping cancel seen — abandon the loop, post nothing ended source exhausted — post END with a final count credits reach 0 CREDIT arrives CANCEL source done CANCEL while parked A parked producer must still be reachable by CANCEL — that dashed edge is the one people omit.
The dashed transition matters more than it looks: without it, cancelling a job whose consumer has stalled leaves a worker parked on a promise that will never resolve.

Wiring It Up

// worker.ts
import { CreditProducer, type ChannelMessage } from './credit-channel';

const producer = new CreditProducer<Float64Array>(
  (m, transfer) => self.postMessage(m, transfer ?? []));

self.addEventListener('message', (e: MessageEvent<ChannelMessage<Float64Array>>) =>
  producer.handle(e.data));

export async function run(source: AsyncIterable<Float64Array>): Promise<void> {
  try {
    for await (const batch of source) {
      const proceed = await producer.send(batch, [batch.buffer]);
      if (!proceed) return;                        // cancelled
    }
    producer.end();
  } catch (error) {
    self.postMessage({ kind: 'ERROR', message: (error as Error).message });
  }
}
// page.ts
const consumer = new CreditConsumer<Float64Array>(
  (m) => worker.postMessage(m),
  async (batch, seq) => { await renderBatch(batch, seq); },
  4,                                              // window
);

worker.addEventListener('message', (e: MessageEvent<ChannelMessage<Float64Array>>) => {
  if (e.data.kind === 'ERROR') return fail(new Error(e.data.message));
  if (e.data.kind === 'END') return complete(e.data.count);
  void consumer.handle(e.data);
});

Note what the consumer’s handle does not do: it does not check whether the job is still relevant. That check belongs inside onChunk, after the credit accounting is already committed by the try/finally — otherwise an early return skips the credit and shrinks the window permanently.


Sizing the Window

The arithmetic is short. Let L be the round-trip latency of the boundary (0.2–1 ms in practice), C the consumer’s cost per chunk, and P the producer’s cost per chunk. To keep the producer busy, the window must cover the time between sending a chunk and getting its credit back:

window ≥ (L + C) / P        rounded up, minimum 2

A consumer that renders in 14 ms against a producer that parses in 2 ms wants a window of about 8; a consumer that costs 1 ms against a producer that costs 20 ms is satisfied by 2. Peak memory is window × chunkSize either way, so the second case is not merely adequate — it is strictly better, and any larger window buys nothing.

Window In flight at 256 kB Producer idle time When it fits
1 256 kB a full round trip per chunk never — pure ping-pong
2 512 kB none if C ≤ P fast consumer, slow producer
4 1 MB none if C ≤ 3P the usual default
8 2 MB none if C ≤ 7P slow consumer, e.g. rendering
64 16 MB none memory spent on nothing

Where chunk sizes vary by an order of magnitude within a job, count bytes instead of messages: grant a 16 MB budget, decrement by byteLength when sending, and return the same number on completion. The invariant becomes “at most 16 MB in flight” regardless of how the producer chose to slice the data.


Metrics That Catch Regressions

Two numbers, exported per job, distinguish every failure this protocol can have.

Depth (sent − acknowledged, on the producer) is the memory ceiling in action. Healthy pipelines oscillate just below the window; a depth pinned at the window for seconds means the consumer has stalled; a depth stuck at zero means the producer is the bottleneck and the window is irrelevant.

Park time (total milliseconds the producer spent awaiting credit) is the tuning signal. Near zero means the window is at least large enough. A large fraction of job time means either the consumer is genuinely the bottleneck — in which case a bigger window will not help, only more consumers will — or the window is too small for the latency.

// producer instrumentation, five lines
let parkedMs = 0;
async function awaitCredit() {
  const t0 = performance.now();
  await park();
  parkedMs += performance.now() - t0;
}
// on END: self.postMessage({ kind: 'STATS', parkedMs, chunks: seq });

Log both with the job’s duration and chunk count. A regression in park time with unchanged depth is almost always a consumer that got slower — a new render path, a larger viewport — and it shows up here days before anyone files a bug about the page feeling sluggish.

Reading depth and park time together A two-by-two matrix of diagnoses. Low depth with low park time means the producer is the bottleneck and the window is irrelevant. Low depth with high park time is impossible and indicates a credit accounting bug, because a parked producer with an empty pipeline means credits were lost. High depth with low park time is a healthy saturated pipeline running at the consumer's pace. High depth with high park time means the consumer is the bottleneck: a larger window will not help, only a faster consumer or more of them. Two numbers, four diagnoses columns: queue depth · rows: producer park time low depth · low park the producer is the bottleneck tune the producer; the window is irrelevant high depth · low park healthy, saturated pipeline running at the consumer's pace, memory bounded low depth · high park impossible — credits were lost find the path that returns without a finally high depth · high park the consumer is the bottleneck a bigger window will not help — parallelise it The pumpkin quadrant is the only one that is a bug rather than a property of the workload.
Keeping both counters is what makes the lower-left quadrant detectable at all — depth alone would look identical to a slow producer.

One Channel Per Job

A single worker often runs several jobs over its lifetime, and sharing one credit counter between them creates a subtle coupling: a stalled job’s outstanding chunks consume the window that a newly started job needs. Giving each job its own MessageChannel removes the interference and simplifies teardown.

// page.ts — a port per job, closed when the job ends
export function startJob(spec: JobSpec) {
  const { port1, port2 } = new MessageChannel();
  const consumer = new CreditConsumer<Float64Array>(
    (m) => port1.postMessage(m), (batch, seq) => renderBatch(batch, seq), 4);

  port1.onmessage = (e) => {
    if (e.data.kind === 'END' || e.data.kind === 'ERROR') { port1.close(); return finish(e.data); }
    void consumer.handle(e.data);
  };

  worker.postMessage({ kind: 'START', spec, port: port2 }, [port2]);
  return () => port1.postMessage({ kind: 'CANCEL' });     // the cancel handle
}

Each job now has an independent window, an independent depth metric, and a teardown that is one close() rather than a filter over shared state. The cost is one MessageChannel per job — a few hundred bytes — and the discipline of closing it on every terminal path, including cancellation.

This also makes the protocol testable in isolation. A test can drive a CreditProducer and a CreditConsumer through a plain MessageChannel in a single thread, with no worker at all, and assert that depth never exceeds the window under an artificially slow consumer. That test catches the credit-accounting bugs below far more reliably than any integration test.


The four invariants a credit protocol has to hold Four invariants. Every chunk sent decrements the balance and every completed chunk returns exactly one credit, so the balance never drifts. The credit is returned after the work, not on arrival, so it represents freed capacity. Every terminal path returns its credit, including errors and early returns, so the window cannot shrink. And a parked producer stays reachable by a cancel message, so a stalled consumer cannot strand it. What has to stay true for the ceiling to hold one credit spent per chunk, one returned per completion the balance never drifts, so the ceiling is exact the credit is returned after the work, never on arrival a credit means freed capacity, not acknowledgement every terminal path returns its credit, errors included a finally block; otherwise the window shrinks to zero a parked producer is still reachable by CANCEL or a stalled consumer strands a worker on a promise Break any one of these and the protocol still runs — it just stops bounding anything.
Three of the four are one-line disciplines. The fourth is the one that only fails once a consumer genuinely stalls.

Gotchas

Returning credit before the work. The single most common error, and it silently restores unbounded queueing. The credit represents freed capacity; sending it on arrival means the producer refills at message rate.

Skipping the credit on an early return. if (job !== currentJob) return; inside the chunk handler leaks one credit per stale chunk. Put every early return inside the try, so the finally still runs.

Cancelling a parked producer. A producer awaiting credit is not running its loop, so a cancelled flag it only checks at the top of the loop is never read. The park promise must also be resolved by the cancel message — the dashed edge in the state diagram.

Assuming ordering across a pool. Sequence numbers are in the protocol above for a reason: with more than one producer, chunks arrive out of order, and a consumer that appends blindly produces scrambled output. Either reassemble by seq or design the consumer to be order-independent.

Priming with a window the consumer cannot honour. The initial grant should match what the consumer can actually hold; a window of 64 chosen “to be safe” means 64 chunks may arrive before the first credit returns, which is precisely the unbounded behaviour in miniature.

Forgetting that transfer detaches. producer.send(batch, [batch.buffer]) detaches batch immediately. A producer that reuses one scratch array across iterations gets an empty buffer on the second send — allocate per chunk, or run a small pool of buffers the consumer returns explicitly.


Performance Note

Streaming 12,000 record batches of 256 kB from a worker to a rendering page on a 2023 laptop: with no flow control, peak process memory reached 740 MB and the tab dropped frames throughout. With a window of 4, peak in-flight memory was 1 MB, total wall clock was within 2% of the uncontrolled run, and frame pacing was steady. The window costs nothing in throughput because the consumer was always the limit — it only changes where the un-rendered data waits.

Frequently Asked Questions

What window size should I start with?
Two chunks, then raise it until throughput stops improving. Two is the floor that keeps the producer from idling for a round trip after every chunk; beyond about eight, extra credits buy nothing on a boundary whose latency is 0.1–0.5 ms and simply raise peak memory. If chunk sizes vary widely, denominate the window in bytes (8–32 MB) rather than messages.
Can a credit protocol deadlock?
Yes, in exactly one way: a consumer that fails to return a credit. Every path that receives a chunk — including the ones that throw, the ones that early-return on a stale job id, and the ones that skip rendering because the view unmounted — must return the credit, which is what a finally block is for. A credit lost per error means the window shrinks to zero after N failures and the producer parks forever.

See also