Module A-5·19 min read

Implementing backpressure when upstream ingestion velocity outpaces downstream write capacity — socket floods, TCP buffers, and the drain event contract.

Written by Jatin Jain Saraf · Architect of SupraScan

Module 4 — The HTTP/TCP Subsystem & Ingestion Backpressure

What this module covers: Backpressure is the mechanism that prevents a fast producer from overwhelming a slow consumer. In a blockchain indexer, the producer is the network delivering transaction events at 50K/sec. The consumer is your PostgreSQL database accepting 5K writes/sec. Without backpressure, your process accumulates an unbounded in-memory queue and eventually OOMs. This module covers how TCP flow control works at the kernel level, how Node.js stream backpressure mirrors it at the application level, and the precise implementation of backpressure for high-throughput ingestion pipelines.


The Problem: Producers Outpacing Consumers

A blockchain indexer at peak load has an inherent mismatch:

  • Producer: blockchain full node pushing 50,000 transaction events/second over TCP
  • Consumer: PostgreSQL database accepting 8,000–12,000 writes/second

Without backpressure, the process does this:

// What happens without backpressure:
socket.on('data', (chunk) => {
  const transactions = parse(chunk);
  for (const tx of transactions) {
    db.write(tx);  // returns a Promise, does NOT wait for it
  }
});
// At 50K tx/sec ingest, 8K/sec db write:
// After 1 second: 42K tx queued in memory
// After 10 seconds: 420K tx queued → ~500MB memory
// After 30 seconds: OOM kill

The fix requires two layers of backpressure working together:

  1. TCP flow control — the kernel tells the sender to slow down when the receive buffer is full
  2. Stream backpressure — Node.js pauses the socket read when the downstream consumer is busy

Understanding how these two layers interact is the key to building a pipeline that never OOMs under load.


TCP Flow Control: The Kernel's Backpressure

TCP has backpressure built in via the receive window mechanism.

Every TCP ACK includes a window field — the number of bytes the receiver is willing to accept. When the kernel's receive buffer fills up, it advertises a smaller window to the sender. When the buffer is full, it advertises zero window — the sender must stop completely.

Normal flow:
  Sender → [50KB data] → Receiver
  Sender ← [ACK, window=65536] ← Receiver (plenty of buffer)
  Sender → [50KB more data] → Receiver
 
Buffer pressure:
  Sender → [50KB data] → Receiver (buffer 95% full)
  Sender ← [ACK, window=4096] ← Receiver (only 4KB left)
  Sender → [4KB data] → Receiver (respects window)
 
Buffer full:
  Sender → [data] → Receiver (buffer 100% full)
  Sender ← [ACK, window=0] ← Receiver (zero window — STOP)
  Sender: blocked. Waiting for window update.
  ... later, when Node.js drains the buffer ...
  Sender ← [window update, window=65536] ← Receiver
  Sender resumes.

The critical insight: TCP flow control automatically propagates backpressure upstream. When Node.js stops reading from the socket (because downstream is slow), the kernel receive buffer fills, the window shrinks to zero, and the blockchain full node is forced to stop sending. The backpressure is communicated all the way back to the data source — no data is dropped, it is simply slowed.

The mailbox analogy: A zero TCP window is the postal equivalent of a mailbox so full the carrier physically can't fit another envelope through the slot — she doesn't lose your letter, she just waits on the porch until you empty the box.


Node.js Streams: Application-Level Backpressure

Node.js streams implement the same backpressure mechanism at the JavaScript level, mirroring TCP flow control.

The highWaterMark (HWM)

Every writable stream has a highWaterMark — the maximum number of bytes (or objects) it is willing to buffer before signalling that it is full.

// Default HWMs:
// Byte streams: 16KB (16384 bytes)
// Object mode streams: 16 objects
 
// Custom HWM for a database write stream:
const dbWriteStream = new Writable({
  objectMode: true,
  highWaterMark: 1000,  // buffer up to 1000 objects before signalling full
  write(transaction, encoding, callback) {
    db.write(transaction)
      .then(() => callback())
      .catch(callback);
  }
});

The write() Return Value: The Backpressure Signal

When you write to a writable stream, it returns a boolean:

  • true — buffer is below HWM, safe to continue writing
  • false — buffer has reached HWM, you should stop writing
// The contract:
const canContinue = writable.write(chunk);
 
if (!canContinue) {
  // STOP writing. The stream has too much buffered.
  // Wait for the 'drain' event before writing more.
}

Production war story: A UPI settlement reconciliation service ignored the boolean return of write() entirely — every call site treated stream.write(data) as fire-and-forget. Normally this was harmless because the downstream consumer kept pace. Then a partner bank's API started responding in a degraded 2 seconds instead of the usual 50ms, at the same time festival load pushed reconciliation volume to several times normal. With no code checking the return value and no drain listener anywhere, the service's internal write buffer grew unbounded for 40 straight minutes — invisible in dashboards that only tracked request rate, not buffer depth — until the process hit its memory ceiling and was OOM-killed at 3 AM. The fix was mechanical (route every write through the writeWithBackpressure helper above) but the incident illustrated the core risk: write()'s return value isn't optional telemetry, it's the only signal standing between a slow downstream and an unbounded buffer.

The drain Event: The Resume Signal

When the stream's internal buffer empties below HWM after being full, it emits drain. This is your signal to resume writing.

// Correct backpressure implementation for a DB write pipeline
function writeWithBackpressure(stream, data) {
  const canContinue = stream.write(data);
  if (!canContinue) {
    return new Promise(resolve => stream.once('drain', resolve));
  }
  return Promise.resolve();
}
 
// Usage in an ingestion loop
async function processIncoming(socket, dbStream) {
  for await (const chunk of socket) {
    const transactions = parseChunk(chunk);
    for (const tx of transactions) {
      await writeWithBackpressure(dbStream, tx);
      // If dbStream is full, this await suspends here
      // The for-await loop pauses, which pauses the socket read
      // Socket pause triggers TCP receive buffer to fill
      // TCP receive buffer full → zero window → sender pauses
      // Backpressure propagated all the way to the data source
    }
  }
}

cork() / uncork(): Micro-Batching Small Writes

When an ingestion pipeline emits many small writes in a tight loop — one write() per parsed transaction, for example — each call can incur its own overhead in the underlying writable (a syscall for a socket, a query round-trip for some DB drivers). writable.cork() tells the stream to buffer everything written until a matching uncork(), so those small writes get coalesced into fewer, larger underlying operations.

// Micro-batch a burst of small transaction writes into one flush
function writeBatchCorked(stream, transactions) {
  stream.cork();
  for (const tx of transactions) {
    stream.write(tx);
  }
  // Uncork on the next tick — everything written since cork() flushes together
  process.nextTick(() => stream.uncork());
}

Two things to keep in mind:

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.