0x55aa
Back to Blog

🚰 Backpressure in Async Pipelines: When Your Queue Cries Uncle

|
5 min read

Here's a scenario that plays out in production more often than anyone wants to admit: a producer service is happily firing events at 5,000/sec, a downstream consumer can only chew through 500/sec, and absolutely nothing in between is watching that gap. Memory climbs. The event loop gets sluggish. Then, at 2 a.m., the process OOMs and somebody's phone starts buzzing.

That gap is where backpressure lives. It's not a fancy pattern — it's just the plumbing question nobody asks until the pipe bursts: what happens when the producer is faster than the consumer?

The bathtub that never learned to say "stop"

Imagine a bathtub with the tap running full blast and a drain the size of a coffee stirrer. Without backpressure, your "tap" (producer) has no idea the "drain" (consumer) is overwhelmed — it just keeps pouring. The overflow doesn't vanish; it becomes an unbounded queue sitting in memory, growing quietly until the process tips over.

Backpressure is the mechanism that lets the drain tell the tap "slow down" — or lets the tub itself say "I'm full, stop pouring." Every layer of an async pipeline needs this signal, and most systems bolt it on only after their first incident.

Where it actually bites you

Node.js gives you a textbook example for free: piping a stream without respecting write()'s return value.

// No backpressure — reads as fast as possible, ignores the drain
readableStream.on('data', (chunk) => {
  writableStream.write(chunk); // fire and forget
});

If writableStream is slower than readableStream (writing to a slow disk, a rate-limited API, a database with lock contention), that internal buffer just grows. The fix is embarrassingly simple once you know it exists:

readableStream.on('data', (chunk) => {
  const canContinue = writableStream.write(chunk);
  if (!canContinue) {
    readableStream.pause();
    writableStream.once('drain', () => readableStream.resume());
  }
});

Or, more honestly: just use .pipe(), which handles this dance for you. But the underlying lesson generalizes way past Node streams — it's the same problem whether you're talking about a message queue, a gRPC stream, or a batch job feeding a database.

Queues don't save you — they just relocate the problem

A common instinct is "just put a queue in between, problem solved." A queue absolutely helps decouple producer and consumer in time, but an unbounded queue is backpressure denial with extra steps. You've traded an OOM in your producer for an OOM in your broker, or a queue depth that silently grows until your on-call dashboard turns red three days later.

The real fix is bounding the queue and deciding, explicitly, what happens when it's full. With something like RabbitMQ, that means setting a channel prefetch so a consumer only pulls as much as it can actually process:

await channel.prefetch(10); // consumer holds at most 10 unacked messages
channel.consume(queueName, async (msg) => {
  await processSlowly(msg);
  channel.ack(msg);
});

With prefetch(10), RabbitMQ stops pushing new messages to that consumer once 10 are outstanding. The broker itself becomes the pressure valve — messages pile up safely in the queue (which is built to hold them) instead of in your consumer's process memory (which isn't).

Kafka's equivalent is consumer lag plus max.poll.records — you're bounding how much a single poll can hand back, and monitoring lag as your actual backpressure signal rather than discovering it via a crash.

The three honest choices

When a producer outpaces a consumer, there are really only three responses, and pretending otherwise is how you end up debugging a memory leak that isn't a leak at all:

  1. Slow the producer down (the stream .pause()/.resume() dance, TCP-style flow control, prefetch limits).
  2. Buffer, but with a bound — and a documented policy for what happens at the bound (block, drop oldest, drop newest, reject with a 429).
  3. Shed load — actively reject or drop work rather than let it queue indefinitely. This overlaps with load-shedding as a pattern, but backpressure is the earlier signal — it's what tells you shedding is even necessary.

Silently unbounded buffering isn't a fourth option, even though it's the default in a lot of code, simply because nobody wrote a Promise.all with a concurrency cap.

Speaking of which — this bites people constantly with Promise.all:

// Fires all 10,000 requests immediately — no backpressure at all
await Promise.all(userIds.map(id => fetchUserData(id)));

Swap in something like p-limit and you've reintroduced a pressure valve at the concurrency level:

const limit = pLimit(20); // at most 20 in flight
await Promise.all(userIds.map(id => limit(() => fetchUserData(id))));

That one-line change is the difference between "graceful, bounded concurrency" and "accidentally DDoS your own downstream API."

A quick war story

At Cubet Techno Labs, we had a notification pipeline that fanned out to email, SMS, and push in parallel for every event. It worked fine in staging with a trickle of test events. Then a client ran a bulk import that generated 40,000 events in a few minutes, and the push-notification provider's rate limiter started rejecting us — but our fan-out code kept firing anyway, retrying failures immediately, which made the rate limiting worse. The fix wasn't cleverer retry logic; it was adding a bounded queue with prefetch in front of each channel so the provider's actual throughput became our throughput too. Backpressure isn't just a performance nicety — it's what keeps a burst of legitimate traffic from turning into a self-inflicted denial of service.

The takeaway

Async pipelines don't fail because producers are fast — they fail because nobody built a way for slowness to travel backward through the pipe. Next time you wire up a stream, a queue consumer, or a batch of parallel promises, ask the boring question up front: when the consumer falls behind, what actually happens? If the honest answer is "the queue grows forever," you don't have a pipeline — you have a countdown timer to an incident.

Go find the nearest unbounded Promise.all or prefetch-less consumer in your codebase. It's probably closer than you think.

Share:LinkedInXHacker News

Related reading

Thanks for reading!

Back to all posts