Node.js interview questions: Streams and backpressure

Streams questions reveal quickly whether someone has built a pipeline or only consumed one. The first prompt may ask why streaming uses less memory, but the real interview begins when the destination slows, chunks split logical records, or cancellation arrives halfway through a write. A senior answer treats backpressure as a protocol between producers and consumers, not as a vague performance feature. A staff answer also covers cleanup, partial output, object-mode accounting, and the operational signals that distinguish healthy buffering from an unbounded queue.

The questions here follow that escalation. Opening answers cover the public API; follow-ups force you to reason about `write()` feedback, `drain`, `pipeline()`, async iteration, high-water marks, and ownership of buffers that remain in flight. The unlocked sample shows how to connect those mechanics to production behavior. Practice answering without assuming one input chunk equals one record or that an error automatically restores application state. The goal is to describe a pipeline that stays bounded, propagates failure, and produces output whose completeness can be trusted after interruption.

Covered in Volume 3: Streams, pipelines, and backpressure
Question 01Fully unlocked sample

So when do you actually reach for a stream instead of just reading the whole thing into memory? What are streams really buying you?

Strong answers treat streams as bounded-memory processing with real flow control, and can do the concurrency-times-payload math that says when you need them

What an AI-prepared candidate might say

So streams basically let you process data in chunks as it comes in, instead of loading the whole thing into memory first. The classic example is a big file. fs.readFile pulls the entire file into one buffer, so a multi-gigabyte file either fails or just eats that much memory, whereas fs.createReadStream goes piece by piece and the footprint stays small and pretty much constant. You also get to start working on the data before all of it arrives, which helps your time to first byte. And they compose, you can pipe a readable through transforms into a writable. HTTP requests and responses in Node are streams too, actually. And you get backpressure kind of for free, so if a slow consumer can't keep up, the stream slows the producer down instead of buffering forever. My rule of thumb is basically, if the data is large or you don't know the size, use streams.

Senior

A stream is really just a bounded queue with a protocol wrapped around it. Chunks, Buffers usually, land in a stage's queue, and highWaterMark is the allowance, 64KiB for byte streams on current Node. Once the queue hits that, the stream tells the producer to stop. So each stage holds about one allowance, and the whole pipeline's memory is the sum of its stages' buffers. A few hundred kilobytes. Doesn't matter if you push terabytes through it. That's the actual purchase here, memory becomes a function of the pipeline's shape instead of the payload's size.

You also get overlap. A buffered read serializes everything, read it all, then process, then write. A pipeline runs all three at once on different chunks, so the first output leaves before the last input even arrives. For a proxy or an export endpoint that turns latency that grows with the payload into near-constant time to first byte.

And the flow control propagates. write() returning false, 'drain', pause/resume, that's how consumer speed travels back up to the producer. With sockets it keeps going into the kernel. Nobody reads the socket, the receive buffer fills, the TCP window closes, and the remote sender just stalls. A slow disk on your end throttles a fast upstream and you wrote zero lines of code for it.

They're not free though. Per-chunk callbacks and queue accounting mean streams lose microbenchmarks against one big read. Error handling and cleanup are a real surface, which is why pipeline exists, hand-wired pipe chains leak. And debugging intermediate state is way harder than staring at one buffer. You're trading code complexity for memory ceilings and TTFB. Great trade at scale, bad trade for a small bounded payload.

Staff

At design review the question I actually care about is concurrency times payload against the memory budget. File size on its own settles nothing. A 5MB response body is nothing once, and it's 5GB at a thousand concurrent requests. That product against the container limit is what decides buffered versus streamed. I make teams write the arithmetic into the design doc, because the failure it prevents is memory that scales with traffic, and that failure passes every functional test and then falls over on launch day. Seen it happen.

But streaming has its own failure modes and you own those too. Partial failure mid-stream is the big one. You can't unsend bytes, so an error at chunk 900 of 1000 has no clean HTTP story, the status line went out ages ago. You deal with it through trailers, client-side length checks, or an idempotent re-fetch. Cleanup's next. Every hand-wired pipe chain that skips pipeline semantics is a candidate fd leak when clients disconnect. And slow-loris dynamics, streaming ties per-request resources to the slowest party, so socket timeouts and concurrency caps stop being optional. Your observability shifts too, bytes-in-flight and stream duration replace plain request timing.

And buffering is still right in places. Bounded small payloads under validated caps, single-digit megabytes at realistic concurrency. Data that needs whole-document processing anyway, JSON you parse into one object, a payload you sign and validate as a unit. Code paths where team familiarity beats a marginal memory win. Either way I verify the same way, soak test at production concurrency and watch RSS and arrayBuffers. If the lines are flat under load, the claim holds. That test is a lot cheaper than the incident.

Follow-up chain

  1. Run the numbers for me. 500 clients each pulling a 1GB file through your service, what does buffered look like next to streamed?
  2. Where does that 64KiB default even come from, and when would you actually change highWaterMark?
  3. And what does it cost you if you crank it way up?
  4. When would you just buffer anyway?
Question 02First answer included

Say the writable side just can't keep up with the readable that's feeding it. Walk me through what happens, step by step.

A strong answer walks the backpressure protocol step by step, from the write() return through buffered bytes to drain, and on down into TCP flow control

What an AI-prepared candidate might say

So when the writable side is slower, its internal buffer starts filling up. Every write() call adds the chunk to that buffer, and once the buffered amount hits the highWaterMark, write() returns false, which is basically telling the producer to stop for a bit. Then the producer is supposed to wait for the 'drain' event, that fires when the buffer has emptied out, and then it can start writing again. If you connect things with pipe() this is all handled for you, it pauses the readable when the destination returns false and resumes it on 'drain'. And that's backpressure, really. The consumer's speed propagates back to the producer so data doesn't pile up in memory. If you're writing manually and you ignore the false return, Node just keeps buffering and memory grows, so you kind of have to respect that false.

Senior

The whole protocol walked in order, buffered bytes against highWaterMark, the advisory false, drain firing at empty, and how a paused socket closes the TCP window on the far end.

Staff

What happens in a fanout service when someone ignores false, the writableLength gauges that catch it early, and slow-consumer policies you can actually defend.

Follow-up chain

  1. Say you just ignore write()'s return value everywhere. Where is all that data physically sitting?
  2. So what does that look like in production, and which metric would catch it early?
  3. Walk me through how pausing a readable socket ends up slowing down the machine on the other end.
  4. You've got a WebSocket fanout and one consumer can't keep up. What are your options, and which one do you actually pick?
Question 03First answer included

What do you actually get from stream.pipeline that chaining .pipe() calls doesn't give you?

A strong answer knows exactly where pipe falls down on errors and cleanup, which streams stay open, what leaks, and what pipeline destroys when

What an AI-prepared candidate might say

So pipe() moves the data and handles backpressure fine, but the error handling is where it falls down. Errors don't propagate through the chain, so each stream needs its own 'error' listener, and if one thing fails the other streams in the chain can just stay open. stream.pipeline() is the fix for that. You pass it the streams plus a callback, or there's a promise version in stream/promises, and it wires up the pipes, forwards the first error to your callback, and cleans up every stream when the pipeline finishes or fails. That cleanup is what stops the resource leaks bare pipe chains are kind of famous for, like file descriptors left open when a destination errors. The rule I've picked up is basically use pipeline for anything production-facing, and treat pipe as the lower-level primitive it wraps.

Senior

The exact sequence when pipe fails, unpipe with no destroy, orphaned sources, error events that crash the process when nobody listens, and pipeline's teardown contract next to it.

Staff

The aborted-download fd leak with real numbers on it, cancellation through AbortSignal, and the one case where pipe with end: false is still the right tool.

Follow-up chain

  1. Okay, a client kills a 2GB download halfway through. Walk me through what happens with pipe, and then with pipeline.
  2. Is there ever a case where you'd still reach for plain pipe?
  3. So how do you safely feed one destination from a bunch of sources without closing it?
  4. What do you actually get from handing pipeline an AbortSignal?
Question 04First answer included

What is highWaterMark actually controlling? And when you flip on objectMode, what changes about how it counts?

A strong answer knows highWaterMark is a soft threshold whose units change by mode, and can do the objectMode memory math the count-based default hides

What an AI-prepared candidate might say

highWaterMark is basically the buffering threshold for a stream. On a writable, once the buffered data reaches it, write() starts returning false and the producer is supposed to wait for 'drain'. On a readable it's how much the stream pre-reads into its internal buffer before it pauses the underlying source. The default is 64KiB for byte streams, I believe. And it's a soft threshold, you can keep writing past it and the stream just keeps buffering. With objectMode: true the units change from bytes to objects, and the default becomes 16, so sixteen objects buffered no matter how big each one is. That's the classic gotcha, sixteen large objects can be a ton of memory even though sixteen sounds small. Tuning it is a memory versus throughput thing, bigger buffers mean fewer pauses and larger reads, smaller ones keep memory tight.

Senior

How the soft threshold really behaves on both sides, writable signaling versus readable read-ahead, and why a Transform is carrying two allowances at once.

Staff

Capacity planning for objectMode when your objects weigh megabytes, how allowances multiply across stages and concurrency, and the fs read-size lever.

Follow-up chain

  1. Why does write() keep taking chunks past the highWaterMark? Why not block, or just throw?
  2. Okay, so if you genuinely need a hard bound, how do you get one?
  3. Your objectMode transform is sitting on sixteen 8MB documents. How do you fix that?
  4. What does highWaterMark on fs.createReadStream actually do to your syscall count?
Question 05First answer included

Tell me how a Transform stream actually works under the hood. And when would you just write a for await loop over a readable instead of building out a pipeline?

A strong answer gets Transform's two-sided buffering, treats the callback as the backpressure lever, and knows what for-await does to a stream on early exit

What an AI-prepared candidate might say

A Transform stream is a duplex stream where you implement _transform(chunk, encoding, callback). Data written to the writable side comes into your function, you do your processing, push() the results out to the readable side, and call the callback to say you're ready for the next chunk. Compression streams and parsers are the textbook examples, I think. The other way to consume a readable is async iteration, so for await (const chunk of stream) pulls chunks one at a time. And because you only pull after your loop body finishes, backpressure kind of just works, the stream won't run ahead of your processing. It reads like normal sequential code, error handling is a regular try/catch, and from what I've seen it's the recommended style for consuming streams when you don't actually need a full pipeline of stages.

Senior

How the _transform callback works as your flow-control lever, why push() inside a transform lands in the readable-side buffer, and the destroy-on-break contract async iteration comes with.

Staff

The per-chunk promise overhead that eats your throughput on tiny chunks, how to add bounded concurrency inside one stage, and generator stages in pipeline as the middle path.

Follow-up chain

  1. In a for-await loop, what's actually providing the backpressure? Where does the pause live?
  2. Say per-chunk awaits are cutting your throughput in half on small chunks. What are your options?
  3. And if you want a few chunks in flight at once inside a single stage, how do you do that safely?
  4. What actually happens to the stream if you break out of that loop early?
Question 06First answer included

Readable streams have this flowing mode and paused mode thing. What's actually different between the two, and what flips a stream from one to the other?

A strong answer knows the readable mode state machine, which APIs flip it, what readableFlowing's three states mean, and where data quietly vanishes

What an AI-prepared candidate might say

So there are two consumption modes on a readable. In flowing mode the stream pushes chunks at your 'data' listeners as fast as they come in. In paused mode you pull explicitly with read(), usually inside a 'readable' handler. Streams start out paused, I'm pretty sure. Then attaching a 'data' listener, calling resume(), or piping to a destination flips it to flowing, and pause() flips it back. Flowing is just the plain push style, and paused gives you control over when and how much you consume. Honestly modern code doesn't really manage this by hand, pipe, pipeline, and for await handle the modes internally. But the modes explain some classic surprises, like a stream that just sits there doing nothing until a 'data' listener shows up and then suddenly fires chunks all at once.

Senior

The three readableFlowing states, exactly which calls move you between them, and the read(n) contract that makes byte-exact protocol parsing clean.

Staff

The mixed-mode bug class that shows up in real codebases, the resume-without-listener trap that loses data silently, and why modern code hides both modes behind pipeline and iterators.

Follow-up chain

  1. What if you attach a 'readable' handler and a 'data' handler on the same stream? Who wins, and what happens?
  2. Say you're parsing a length-prefixed protocol. How do you read exactly N bytes off the stream?
  3. And when read(n) keeps handing you null, what's it trying to tell you?
  4. Someone calls resume() on a stream that has no 'data' listener at all. Where do those chunks end up?
Question 07First answer included

Say you have to proxy a big HTTP response through your service without buffering the whole thing. How do you do it, and what actually breaks if you get it wrong?

A strong answer treats the HTTP request and response objects as the streams they are, and can build a proxy whose memory never tracks body size

What an AI-prepared candidate might say

So in Node the incoming request is a readable stream and the response you're sending is a writable, and a proxy is basically just connecting the two. You make the upstream request, then pipe the upstream response into your server response, and you have to write the status code and headers before the body starts. With stream.pipeline(upstreamRes, res) the data flows through in chunks and memory stays flat no matter how big the body is. If your memory scales with response size, you're buffering somewhere, collecting the whole upstream body before sending it on, like with await response.json() or concatenating chunks by hand or something. Backpressure comes along for free, if the client is slow the pipe slows the upstream read down too. And you need error handling for either side dying mid-transfer, which pipeline covers by cleaning up both streams.

Senior

The anatomy of req and res as streams, getting headers out before the body flows, and the end-to-end backpressure that lets a slow phone throttle your upstream fetch.

Staff

Timeout budgets for each phase of a transfer, the rule about only retrying before the first byte, compression passthrough, and the fd metrics that catch abort leaks.

Follow-up chain

  1. Okay, the client drops the connection halfway through the transfer. Walk me through the cleanup on both sides.
  2. And concretely, what events and errors do you actually see fire on each side?
  3. Is there anywhere a streaming proxy can still retry safely?
  4. Mechanically, how does a slow client on your proxy end up slowing down the upstream server?
Question 08First answer included

Here's the task. Ten gigabyte file, you transform every line, and memory has to stay flat the whole way through. Walk me through your design.

A strong answer designs a bounded-memory file pipeline, handles split lines and split characters at chunk boundaries, and proves flat memory with metrics

What an AI-prepared candidate might say

I'd build a pipeline with three stages, basically. fs.createReadStream reads the input, a transform splits the chunks into lines and applies the per-line change, and a writable stage writes the output file. You wire them with stream.pipeline so if anything errors the whole chain gets torn down. The read stream emits chunks of around 64KiB, so memory only ever holds a chunk or two instead of the whole file. The tricky bit is chunks don't line up with line endings, so the splitter keeps the trailing partial line in a small buffer and sticks it in front of the next chunk. readline.createInterface or a split transform handles that for you. Backpressure stops the reader outrunning the writer, so the footprint stays constant from the first byte to the ten-billionth. To verify I'd just watch process.memoryUsage() during a run, it should plateau early and stay flat regardless of file size.

Senior

The full pipeline with carry-buffer line splitting, the multibyte-character boundary problem StringDecoder exists for, and where the memory bound actually comes from.

Staff

Guarding against the pathological line, checkpointing so a dead job can resume, when parallelizing the per-line work pays off, and the soak test that turns 'flat' into a measured claim.

Follow-up chain

  1. So a chunk ends right in the middle of a multibyte UTF-8 character. Whose job is that, and how does it get handled?
  2. Now one of those lines turns out to be 2GB. What happens to your flat-memory claim?
  3. And how would you defend against that?
  4. Say the per-line transform is CPU-heavy. Where would you put the parallelism?
Question 09First answer included

When you destroy a stream, what actually happens under the hood? And when a socket dies mid-transfer, how do you clean up properly?

A strong answer keeps end, finish, and close straight, knows what destroy releases, and reaches for finished or pipeline instead of hand-wiring terminal events

What an AI-prepared candidate might say

destroy() is the forceful teardown, basically. It releases the underlying resource, the socket or the file descriptor, emits an 'error' if you passed one in, and then emits 'close'. After that the stream is unusable and further writes fail. The events break down roughly like, 'end' fires on a readable once all the data has been consumed, 'finish' fires on a writable once everything you wrote has been flushed to the underlying system, and 'close' fires when the stream and its resource are actually released. When a socket dies mid-transfer, the streams attached to it need to get destroyed too or they leak. Which is why the advice is stream.pipeline, or stream.finished for a single stream. They watch every terminal path, error, premature close, completion, and destroy the chain instead of leaving pieces open. ERR_STREAM_PREMATURE_CLOSE is what you get when a stream closed before it signaled completion, I believe.

Senior

The destroy sequence walked step by step, the end, finish, and close ladder on both stream types, and what premature close is really telling you.

Staff

The CLOSE_WAIT pileup that points at missing destroys, the durability-versus-finish gap on file streams, and how to chaos-test the disconnect path in CI.

Follow-up chain

  1. You look at the box and sockets are piling up in CLOSE_WAIT after client disconnects. What's the stream-level bug?
  2. And which API would've stopped that from happening, and how?
  3. So finish fired, then the machine lost power, and the data never made it to disk. How does that happen?
  4. When would you actually call destroy() yourself, and what do you pass it?
Question 10First answer included

So fetch gives you a web ReadableStream, but your storage SDK wants a Node stream. What's actually different between the two models, and how do you bridge them without breaking anything?

A strong answer knows how flow control works in both stream models, plus the toWeb/fromWeb bridges and their error and cancellation caveats

What an AI-prepared candidate might say

Node ships two stream families, so there's the classic Node streams, Readable, Writable, events, pipe, and then the WHATWG web streams, ReadableStream, WritableStream, TransformStream, which came over from the browser standard. A fetch body is a web stream. The main difference is style, I think. Node streams are event-based and push-oriented, web streams are promise-based and pull-oriented, with readers and controllers instead of events. For interop Node gives you official converters, Readable.toWeb() and Readable.fromWeb(), and the same methods exist on Writable and Duplex. So for the fetch-to-SDK case you'd write Readable.fromWeb(response.body) and then pipe or pipeline into the SDK's stream like normal. Backpressure works across the conversion, from what I understand. Web streams are the more portable choice since they run in browsers, edge runtimes, and Node alike, and Node streams stay the native fit for Node's own APIs like fs and http.

Senior

The pull-based controller model, desiredSize, queuing strategies, locked readers, laid alongside Node's event machinery, and what the official bridges map where.

Staff

The convert-at-the-edges rule for your architecture, how errors and cancellation behave across the bridge, and the per-chunk overhead question you benchmark before any migration.

Follow-up chain

  1. In a web ReadableStream, where does the backpressure actually live?
  2. When errors or a cancellation cross the toWeb or fromWeb bridge, what happens to them?
  3. And if you want to kill the whole bridged pipeline with one signal, how do you wire that?
  4. Say you're publishing a library that hands streams to its callers. Which model do you expose?

THE BASELINE GETS YOU THROUGH QUESTION ONE.

Raw Mode trains the follow-ups.

Unlock every Senior and Staff answer, every tree answer, the debug repos, hallucination drills, design scenarios, and the framework capstone.Get NodeBook Raw Mode