Skip to the solution
MediumSystem Design15 min read

What is backpressure and how do you handle it?

104 views

The problem

Question presented to candidate: "A producer in your system, say an ingestion API or an upstream service, can sometimes send work faster than the downstream consumer can process it. What is backpressure, what goes wrong if you ignore it, and what are your options for handling it?"

What a strong answer should cover:

  • The definition: a way for a slow consumer to signal a fast producer to slow down, so work is not accepted faster than it can be finished
  • Why ignoring it fails: an unbounded buffer grows until memory runs out, and latency grows with it (Little's law: wait = queue length / service rate)
  • The options: bound the buffer, then block or slow the producer, shed load (drop or reject with 429/503), or switch to pull / credit-based flow where the consumer asks for N items
  • Real mechanisms: TCP flow control (receive window), Node.js streams (write() returning false, the 'drain' event, pipeline()), Reactive Streams request(n), pull-based consumers like Kafka
  • Backpressure must propagate to the edge, where a client can be told to back off; buffering in the middle only delays the problem
  • Trade-offs: blocking keeps every message but adds latency upstream; dropping keeps latency bounded but loses work; choose per data type

Code / implementation expected: Optional โ€” explaining write()/'drain' or a bounded queue is a plus; the examples simulate four strategies and run real Node.js stream backpressure.

  • backpressure

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read ยท 12 sections

Read the code

Backpressure strategies on a fake clock: unbounded queue, load shedding, blocking the producer and credit-based pull
// Backpressure on a fake clock: a producer bursts at 150 msg/s for 10 s, then sends
// 50 msg/s; the consumer can do 100 msg/s. Four ways to handle the gap.
const SECONDS = 20, CAPACITY = 100, BOUND = 100;
const offered = (t) => (t < 10 ? 150 : 50);

function simulate(strategy) {
  const queue = []; // FIFO of [enqueueSecond, count]
  let depth = 0, dropped = 0, backlog = 0, maxDepth = 0, maxWait = 0, maxBacklog = 0, done = 0, offeredTotal = 0;
  const depths = [];
  for (let t = 0; t < SECONDS; t++) {
    let arriving = offered(t);
    offeredTotal += arriving;
    if (strategy === "block" || strategy === "credits") { arriving += backlog; backlog = 0; }
    let room = strategy === "unbounded" ? Infinity : BOUND + CAPACITY - depth; // what fits this second
    if (strategy === "credits") room = Math.min(room, CAPACITY); // consumer grants credits = its rate
    const accepted = Math.min(arriving, room);
    if (strategy === "drop") dropped += arriving - accepted;
    else backlog += arriving - accepted; // block / credits: producer holds it (upstream waits)
    if (accepted > 0) { queue.push([t, accepted]); depth += accepted; }
    let budget = CAPACITY; // consumer drains up to 100 this second
    while (budget > 0 && queue.length) {
      const seg = queue[0];
      const n = Math.min(budget, seg[1]);
      seg[1] -= n; budget -= n; depth -= n; done += n;
      maxWait = Math.max(maxWait, t - seg[0]);
      if (seg[1] === 0) queue.shift();
    }
    maxDepth = Math.max(maxDepth, depth);
    maxBacklog = Math.max(maxBacklog, backlog);
    depths.push(depth);
  }
  return { depths, maxDepth, maxWait, dropped, maxBacklog, done, offeredTotal, left: depth + backlog };
}

console.log(`offered: 150/s for t=0..9, 50/s for t=10..19; consumer 100/s; bound ${BOUND}`);
console.log("queue depth at the end of each second:");
const rows = [];
for (const s of ["unbounded", "drop", "block", "credits"]) {
  const r = simulate(s);
  rows.push([s, r]);
  console.log(`  ${s.padEnd(10)} ${r.depths.map((d) => String(d).padStart(3)).join(" ")}`);
}
console.log("\nstrategy    max depth  max wait  dropped  max producer backlog  processed  still pending");
for (const [s, r] of rows)
  console.log(`  ${s.padEnd(10)} ${String(r.maxDepth).padStart(9)} ${String(r.maxWait + " s").padStart(9)} ${String(r.dropped).padStart(8)} ${String(r.maxBacklog).padStart(21)} ${String(r.done).padStart(10)} ${String(r.left).padStart(14)}`);

console.log("\nsustained overload with an unbounded queue (in 120/s, out 100/s, 2 KB per message):");
for (const minutes of [1, 10, 60]) {
  const queued = 20 * 60 * minutes;
  console.log(`  after ${String(minutes).padStart(2)} min: ${String(queued).padStart(6)} queued, ${String((queued * 2) / 1000).padStart(5)} MB, newest waits ${queued / 100} s`);
}
Node.jsReal Node.js stream backpressure: write() returning false, waiting for drain, and pipeline()
// Real Node.js stream backpressure: write() returns false once the buffer reaches
// highWaterMark; a well-behaved producer waits for 'drain'. Node 22 default HWM shown.
const { Writable } = require("node:stream");
const { once } = require("node:events");

const CHUNK = Buffer.alloc(16 * 1024); // 16 KiB per write
const TOTAL = 20; // 320 KiB in all

function slowSink() {
  // A consumer that finishes one chunk per event-loop turn (setImmediate).
  return new Writable({ write(chunk, enc, cb) { setImmediate(cb); } });
}

console.log(`node ${process.version.split(".")[0]}: default writableHighWaterMark = ${slowSink().writableHighWaterMark} bytes`);

async function ignoring() {
  const w = slowSink();
  let firstFalse = -1, peak = 0;
  for (let i = 0; i < TOTAL; i++) {
    const ok = w.write(CHUNK);
    if (!ok && firstFalse < 0) firstFalse = i + 1;
    peak = Math.max(peak, w.writableLength);
  }
  w.end(); await once(w, "finish");
  console.log(`ignore false: write() first returned false on write #${firstFalse}, kept writing; peak buffered = ${peak / 1024} KiB`);
}

async function respecting() {
  const w = slowSink();
  let peak = 0, waits = 0;
  for (let i = 0; i < TOTAL; i++) {
    const ok = w.write(CHUNK);
    peak = Math.max(peak, w.writableLength);
    if (!ok) { waits++; await once(w, "drain"); }
  }
  w.end(); await once(w, "finish");
  console.log(`respect false: paused for 'drain' ${waits} times; peak buffered = ${peak / 1024} KiB`);
}

async function piped() {
  const { Readable, pipeline } = require("node:stream");
  let produced = 0, peak = 0;
  const src = new Readable({
    read() { produced++; this.push(produced <= TOTAL ? CHUNK : null); },
  });
  const w = slowSink();
  await new Promise((res, rej) => {
    const orig = w.write.bind(w);
    w.write = (c, ...a) => { const r = orig(c, ...a); peak = Math.max(peak, w.writableLength); return r; };
    pipeline(src, w, (err) => (err ? rej(err) : res()));
  });
  console.log(`pipeline():   ${TOTAL} chunks moved; peak buffered in the sink = ${peak / 1024} KiB`);
}

(async () => { await ignoring(); await respecting(); await piped(); })();

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 52 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

What is a Bloom filter and when is it useful?

Continue