Chapter 9 — Streams and Async Iterators
Pulling data in pieces: iterators, backpressure, pipeline, Web Streams, and LLM output over SSE
Prerequisites: Chapter 4 — async/await Traps, Chapter 6 — Cancellation and Cleanup, Chapter 7 — Controlling Concurrency, Chapter 8 — The Node Process Under Failure (memory and the blocked loop). Practice: the gym, exercise 08-stream-parser.
How to read this chapter
A stream is data that arrives in pieces over time: a file read in 64 KB chunks, an HTTP body, an LLM's tokens. Code that handles streams has three jobs that a plain await res.json() never has: reassemble pieces that were split at arbitrary points, slow the producer down when the consumer is slower (backpressure), and tell the producer to stop when nobody wants the rest (cancellation). Every bug in this chapter is one of those three jobs not done.
Part 1 is the protocol under all of it (iterators and for await). Parts 2 and 3 are Node streams and Web Streams. Part 4 is the LLM case, with the parser you build. Part 5 is composing pipelines, with the second build.
| Term | Meaning |
|---|---|
| Iterator | An object with next() returning { value, done }. Optional return() and throw() |
| Async iterator | The same, but next() returns a promise. for await consumes it |
| Async generator | async function*. Produces an async iterator; yield pauses it until the consumer asks for more |
| Chunk | One piece delivered by a stream. Its boundaries have nothing to do with the boundaries of your messages |
| Backpressure | The consumer signalling "slow down" so data does not pile up in memory |
highWaterMark | The buffer size at which a Node stream starts signalling backpressure (64 KB by default for byte streams in Node 22; 16 KB before) |
| Pull vs push | The consumer asks for the next piece (iterators, Web Streams) vs the producer sends when it likes ('data' events) |
| SSE | Server-Sent Events: a text/event-stream body made of field: value lines, with a blank line ending each event |
Part 1: Iterators, async generators, and what break does
for (const x of obj) calls obj[Symbol.iterator]() and then next() until done. for await (const x of obj) does the same with Symbol.asyncIterator and awaits each next(). A generator function writes the iterator for you, and the important property is that it is lazy: the body runs only as far as the next yield, and only when the consumer calls next(). That laziness is backpressure for free: a generator cannot get ahead of its consumer.
async function* rows(fetchPage) {
let cursor = null;
const conn = await pool.connect();
try {
do {
const page = await fetchPage(conn, cursor);
for (const row of page.rows) yield row;
cursor = page.next;
} while (cursor);
} finally {
conn.release();
}
}
for await (const row of rows(fetchPage)) {
if (row.id === 3) break;
}
break calls the iterator's return() method. For a generator, return() resumes it at the paused yield as if that yield were a return statement, so its finally blocks run and the connection is released. The same happens on return from inside the loop and on a throw out of the loop body. In a test, finally ran on break before the next line after the loop.
Two ways to lose that cleanup:
- Calling
next()by hand and stopping.const g = rows(...); await g.next();and then droppinggnever runsfinally. The generator stays paused forever, holding its connection, until it is garbage collected (and GC does not runfinally). If you drive an iterator manually, callg.return()when you stop. - Wrapping it in something that does not forward
return(). A hand-written iterator object with onlynext()cannot be cleaned up bybreak.
Spot the bug: for await over an array of promises
const calls = providers.map((p) => p.complete(prompt)); // all start now
try {
for await (const reply of calls) {
render(reply);
}
} catch (err) {
showError(err);
}
Provider B fails quickly while A is still running. What happens?
Answer
The process crashes with an unhandled rejection; the catch never runs. for await over an array awaits the elements one at a time. While it is awaiting calls[0], nothing has attached a handler to calls[1]. When calls[1] rejects, the microtask queue drains, Node finds a rejected promise with no handler, and (Chapter 8) exits with code 1. A test with A resolving at 100 ms and B rejecting at 10 ms crashes at 10 ms.
Use the tool built for "several promises, handle each": Promise.allSettled(calls) and then loop over the results, or attach handlers up front (calls.map((p) => p.then(ok, fail))). The same trap is inside any hand-written "start K, yield in order" helper, which is why mapConcurrent in Part 5 attaches a no-op catch to each promise when it starts it.
Say it: "for await attaches handlers one at a time, so a later promise that rejects early has no handler and crashes the process. These promises aren't a stream; use allSettled."
Part 2: Node streams
Node has four kinds: Readable (a file, an HTTP request body on the server, a response body on the client), Writable (a file, res on the server), Duplex (a socket) and Transform (gzip, a parser). Every Readable is also an async iterable, so for await (const chunk of readable) is the simplest way to consume one, and it handles backpressure: the stream does not read more than its buffer while your loop body is busy.
Backpressure on the write side
writable.write(chunk) always accepts the chunk. Its return value is the only signal: false means "my buffer is above highWaterMark; stop until I emit 'drain'". Ignore it and the chunks pile up in memory.
import { once } from 'node:events';
for (const row of rows) {
if (!res.write(toCsvLine(row))) await once(res, 'drain');
}
res.end();
Measured against a writable with a 16 KB highWaterMark that takes one 1 KB chunk per millisecond (a slow mobile client): writing 50,000 chunks while ignoring the return value left 51.2 MB buffered in the process, with write() returning false 49,985 times. The loop that waits for 'drain' never had more than 15 KB buffered. Multiply the first number by a few hundred concurrent CSV exports and the process is OOM-killed.
pipeline vs .pipe
src.pipe(dest) handles backpressure, but not errors or cleanup:
- An error on
srcis not forwarded todest, and an'error'event with no listener throws.createReadStream('missing.csv').pipe(gzip).pipe(res)crashes the process withENOENT(tested: exit code 1). - If
destfails or the client disconnects,srcis unpiped but not destroyed. In a test, after the destination errored, the file stream was still open with its file descriptor held. Under load that is anEMFILEincident.
pipeline from node:stream/promises fixes both: it forwards errors, destroys every stream in the chain when any one fails or closes early, and returns a promise.
import { pipeline } from 'node:stream/promises';
import { createReadStream } from 'node:fs';
import { createGzip } from 'node:zlib';
app.get('/exports/:id', async (req, res) => {
res.setHeader('content-type', 'application/gzip');
try {
await pipeline(createReadStream(exportPath(req.params.id)), createGzip(), res);
} catch (err) {
if (!res.headersSent) res.status(500).end();
// client disconnects show up here as ERR_STREAM_PREMATURE_CLOSE; nothing to do
}
});
In the same test that left .pipe's source open, pipeline rejected with the destination's error and the source had destroyed === true.
pipeline also accepts async generator functions as transforms, which is usually simpler than subclassing Transform (Part 5 uses this).
Memory: buffering the whole thing
Hashing a 300 MB export file:
| Approach | Resident memory after |
|---|---|
await readFile(path) then hash.update(all) | 346 MB |
await pipeline(createReadStream(path), hash) | 92 MB |
The same applies to await res.text() or res.json() on a big upstream response, Buffer.concat(chunks) "so we can parse it at the end", stream.toArray(), and JSON.parse of a whole NDJSON export (which also blocks the loop, Chapter 8). If the data is a sequence of records, process it as a sequence.
Part 3: Web Streams
fetch (in Node 18+ and browsers) returns its body as a Web ReadableStream, a different API from Node streams with the same ideas: pull-based reading, backpressure through a queue with a highWaterMark, and cancellation with cancel().
const res = await fetch(url, { signal });
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
parser.push(value); // a string
if (enough()) break;
}
} finally {
await reader.cancel(); // tell the source we are done; fetch closes the connection
}
getReader()locks the stream to that reader. Nothing else can read it untilreleaseLock(). A secondgetReader()throws.reader.cancel()propagates upstream: throughpipeThrough, to the fetch body, which closes the HTTP connection. In a test against a server streaming a token every 10 ms, the server saw the connection close 2 ms aftercancel(). When the code read one chunk and then simply stopped, withoutcancel(), the server was still streaming 500 ms later.TextDecoderStreamdecodes UTF-8 across chunk boundaries correctly (Part 4 explains why that matters).- In Node, a
ReadableStreamis async iterable, sofor await (const chunk of res.body)works, andbreakcancels the stream throughreturn()(tested: the server saw the close within 1 ms). Browser support for async iteration ofReadableStreamarrived later and unevenly; check your targets, or usegetReader()in browser code. - Convert between the two worlds with
Readable.fromWeb(webStream)andReadable.toWeb(nodeStream).
Part 4: LLM streaming over SSE
Provider streaming APIs send text/event-stream. The wire format is simple:
event: content_block_delta
data: {"type":"content_block_delta","delta":{"text":"Hel"}}
data: {"choices":[{"delta":{"content":"lo"}}]}
: this line is a comment, often a keep-alive
data: [DONE]
Each line is field: value (one optional space after the colon). Lines may end in \n, \r\n or \r. data lines accumulate (several data: lines in one event are joined with \n), event sets the type, id sets the last event id, and a blank line dispatches the event. Lines starting with : are comments.
The chunk-boundary bugs
The network does not know about lines. A chunk can end anywhere: in the middle of a data: line, in the middle of the JSON, between the \r and \n of a line ending, and in the middle of a multi-byte UTF-8 character.
Why { stream: true }: a TextDecoder called with decode(bytes, { stream: true }) keeps the trailing bytes of an incomplete character inside the decoder and emits them with the next call. One decoder must live for the whole stream; call decoder.decode() with no arguments at the end to flush. TextDecoderStream, and a Node stream with encoding: 'utf8' (which uses StringDecoder), do this for you.
Spot the bug: the token reader
export async function streamCompletion(prompt, onToken) {
const res = await fetch(PROVIDER_URL, { method: 'POST', headers, body: JSON.stringify({ prompt, stream: true }) });
const reader = res.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = Buffer.from(value).toString('utf8');
for (const line of text.split('\n')) {
if (line.startsWith('data: ')) {
const payload = line.slice(6);
if (payload === '[DONE]') return;
onToken(JSON.parse(payload).choices[0].delta.content);
}
}
}
}
It works in every local test. In production, about one response in fifty throws SyntaxError: Unexpected end of JSON input, and users with non-English text see � now and then. What's wrong?
Answer
- It parses per chunk, not per line. A
data:line split across tworead()calls is parsed as two broken halves: the first half throws inJSON.parse, the second half does not start withdata:and is dropped. Local tests pass because localhost delivers big chunks; real networks split anywhere. - It decodes each chunk separately.
Buffer.from(value).toString('utf8')on half a multi-byte character producesU+FFFD. A test splitting "€" between chunks gets replacement characters with bothBuffer#toStringand a freshTextDecoderper chunk, and the correct "€" with one decoder and{ stream: true }. - It ignores
\r\nand multi-linedata. With\r\nendings,payloadis'[DONE]\r', so the[DONE]check never matches. returnon[DONE]leaves the reader locked and the body unread. The connection stays busy until GC; on an early return from an error it keeps receiving tokens. Cancel the reader infinally.- No
signal: the caller cannot stop it (next section), and no check ofres.okbefore reading an error body as a stream.
export async function* streamCompletion(prompt, { signal } = {}) {
const res = await fetch(PROVIDER_URL, { method: 'POST', headers, body: JSON.stringify({ prompt, stream: true }), signal });
if (!res.ok) throw Object.assign(new Error(`provider ${res.status}`), { status: res.status });
for await (const ev of sseEvents(res.body)) { // the build below; break cancels res.body
if (ev.data === '[DONE]') return;
yield JSON.parse(ev.data).choices[0].delta.content ?? '';
}
}
Say it: "Chunk boundaries are arbitrary, so the parser has to buffer the incomplete line and decode with one streaming TextDecoder. Parse per event, not per chunk, and cancel the body when you stop early."
Build: an SSE parser that handles any chunking
Spec:
createSSEParser(onEvent)returns{ push(text), end() }.pushaccepts strings split at any point. For each complete event, callonEvent({ event, data, id, retry }), witheventdefaulting to'message'.- Handle
\n,\r\nand\rline endings, including a\r\nsplit across two pushes. Ignore comment lines and unknown fields. Remove one leading space from the value. Join multipledatalines with\n. An event with nodatalines is not dispatched. Atend(), drop an unfinished event (the spec says so). sseEvents(byteStream)is an async generator over any async iterable ofUint8Array(a fetch body, a Node stream) that decodes UTF-8 correctly across chunks and yields events.- Test it by feeding every possible split point.
Reference solution
export function createSSEParser(onEvent) {
let buf = ''; // the incomplete last line
let pendingCR = false; // last chunk ended in '\r'; a '\n' next is part of the same break
let data = [];
let event = '';
let lastEventId = '';
let retry;
function processLine(line) {
if (line === '') return dispatch(); // a blank line ends the event
if (line[0] === ':') return; // comment / keep-alive
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? '' : line.slice(colon + 1);
if (value[0] === ' ') value = value.slice(1); // one optional space
if (field === 'data') data.push(value);
else if (field === 'event') event = value;
else if (field === 'id') { if (!value.includes('\0')) lastEventId = value; }
else if (field === 'retry') { if (/^\d+$/.test(value)) retry = Number(value); }
// unknown fields are ignored
}
function dispatch() {
if (data.length > 0) {
onEvent({ event: event || 'message', data: data.join('\n'), id: lastEventId, retry });
}
data = [];
event = '';
}
return {
push(chunk) {
if (chunk === '') return;
let i = 0;
if (pendingCR) {
pendingCR = false;
if (chunk[0] === '\n') i = 1; // the '\n' of a '\r\n' split across chunks
}
let start = i;
for (; i < chunk.length; i++) {
const c = chunk[i];
if (c !== '\n' && c !== '\r') continue;
processLine(buf + chunk.slice(start, i));
buf = '';
if (c === '\r') {
if (i + 1 === chunk.length) pendingCR = true;
else if (chunk[i + 1] === '\n') i++;
}
start = i + 1;
}
buf += chunk.slice(start);
},
// End of stream: an event without its closing blank line is dropped (per the spec).
end() {
buf = '';
data = [];
event = '';
},
};
}
export async function* sseEvents(byteStream) {
const decoder = new TextDecoder(); // UTF-8; also strips a leading BOM
const queue = [];
const parser = createSSEParser((e) => queue.push(e));
for await (const bytes of byteStream) {
parser.push(decoder.decode(bytes, { stream: true })); // keep a split character for later
while (queue.length) yield queue.shift();
}
parser.push(decoder.decode()); // flush any bytes still held
parser.end();
while (queue.length) yield queue.shift();
}
The test feeds one fixture through the parser every way it can be cut. The fixture has a comment, an event: field, \r\n between two fields of the same event, \r-only endings, data: with no space, multi-line data, id, retry, an unknown field, a bare data line, two-, three- and four-byte UTF-8 characters, [DONE], and an unfinished event at the end.
import { test } from 'node:test';
import assert from 'node:assert/strict';
const BYTES = new TextEncoder().encode(STREAM); // the fixture
async function parseBytes(chunks) {
const out = [];
async function* source() { for (const c of chunks) yield c; }
for await (const e of sseEvents(source())) out.push(e);
return out;
}
test('every pair of split points (three chunks)', async () => {
for (let i = 0; i <= BYTES.length; i++) {
for (let j = i; j <= BYTES.length; j++) {
const got = await parseBytes([BYTES.subarray(0, i), BYTES.subarray(i, j), BYTES.subarray(j)]);
assert.deepEqual(got, EXPECTED, `split at ${i},${j}`);
}
}
});
test('one byte at a time', async () => {
assert.deepEqual(await parseBytes(Array.from(BYTES, (b) => Uint8Array.of(b))), EXPECTED);
});
That is 28,920 three-chunk splits, plus every two-chunk split of the bytes and of the string, plus one byte at a time. To check that the test can fail, two deliberate breakages were tried: dropping the "skip the \n after a split \r" line, and decoding without { stream: true }. Each one fails the split tests.
Edge cases interviewers check:
\r\nsplit across chunks. If the parser treats the\nat the start of the next chunk as a new, empty line, it dispatches early and loses theevent:type of the event in progress.- The decoder is created once per stream, not per chunk, and flushed at the end.
- Parse JSON per event, not per line or per chunk. A provider may split one JSON document over several
data:lines. - Events are queued and yielded after each push, so a slow consumer applies backpressure to the source: the generator does not read the next chunk until the consumer has taken the events from this one.
- The last event: the spec drops an event that has no closing blank line. Some providers end without one; if yours does, flush on
end()deliberately and say so.
Cancelling when the client disconnects
Your server sits between the user and the provider: the browser calls /chat, your handler calls the provider with stream: true, and forwards the events. If the user closes the tab, the provider does not know unless you tell it. It keeps generating, and you keep paying for output tokens nobody will read.
The handler, with cancellation and backpressure:
app.post('/chat', async (req, res) => {
const ac = new AbortController();
res.on('close', () => {
if (!res.writableFinished) ac.abort(); // closed before we finished: the client left
});
try {
const upstream = await fetch(PROVIDER_URL, {
method: 'POST',
headers: providerHeaders,
body: JSON.stringify({ ...req.body, stream: true }),
signal: ac.signal,
});
res.writeHead(upstream.status, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
for await (const chunk of upstream.body) {
if (!res.write(chunk)) await once(res, 'drain', { signal: ac.signal });
}
res.end();
} catch (err) {
if (!ac.signal.aborted) res.destroy(err); // a real error, not a disconnect
}
});
Or let pipeline do the forwarding: await pipeline(Readable.fromWeb(upstream.body), res). It applies backpressure and, when res closes early, destroys the upstream stream, which cancels the fetch body. Both versions were tested against a fake provider that streams a token every 10 ms: the provider saw its connection close 3 ms after the client disconnected and had sent 5 tokens, the same number the client read. Without cancellation, it had sent 30 by 300 ms later and was still going.
Details that matter:
once(res, 'drain', { signal }): without the signal, a client that disconnects while you wait for'drain'leaves the handler waiting forever, because a closed response never emits'drain'.- Listen on
res, notreq. See the drill. - If you parse events on the way through (to count tokens, store the reply, or charge credits), use
sseEventson the upstream body and re-serialise; record what was actually delivered, and charge for what the provider generated up to the abort.
Spot the bug: the abort that fires too soon
app.post('/chat', async (req, res) => {
const ac = new AbortController();
req.on('close', () => ac.abort()); // stop the provider if the user leaves
const upstream = await fetch(PROVIDER_URL, { method: 'POST', body: JSON.stringify(req.body), signal: ac.signal });
res.writeHead(200, { 'content-type': 'text/event-stream' });
for await (const chunk of upstream.body) res.write(chunk);
res.end();
});
Every request fails at once with AbortError, and the client gets nothing. What's wrong?
Answer
req emits 'close' as soon as the request body has been read, not when the client disconnects. req is the readable side: the incoming request. Since Node 16 it auto-destroys once it has been fully consumed, and 'close' follows. The body parser (here express.json()) has read the body before the handler runs, so the abort fires within milliseconds of the request starting. In a test, 'close' on req fired 8 ms after the request began, the fetch rejected with AbortError, and the client received zero events.
The client leaving is a property of the response: res emits 'close' when the underlying connection closes, and res.writableFinished tells you whether you got to end() first.
res.on('close', () => {
if (!res.writableFinished) ac.abort();
});
The loop also ignores backpressure (res.write return value), there is no try/catch around the stream (an abort or provider error becomes an unhandled rejection in Express 4), and it writes 200 even when the provider returned 429.
Say it: "req closes when the request body is consumed, which is before the stream even starts. Disconnect detection belongs on res, checking writableFinished, and the abort signal has to reach both the fetch and any wait for drain."
Part 5: Async iterator pipelines
Async generators compose. Each stage takes an async iterable and returns one, and because each pulls from the stage before it only when asked, the whole chain runs at the speed of the slowest stage with a bounded amount in memory.
async function* lines(source) {
let rest = '';
for await (const chunk of source) {
rest += chunk;
const parts = rest.split('\n');
rest = parts.pop(); // the last piece may be half a line
yield* parts;
}
if (rest) yield rest;
}
async function* filter(source, pred) {
for await (const x of source) if (pred(x)) yield x;
}
await pipeline(
createReadStream('docs.ndjson', { encoding: 'utf8' }), // StringDecoder: split characters are safe
lines,
async function* (src) { for await (const line of src) yield JSON.parse(line); },
(src) => filter(src, (doc) => !doc.deleted),
(src) => mapConcurrent(src, 8, embed), // the build below
async function* (src) { for await (const row of src) yield JSON.stringify(row) + '\n'; },
createWriteStream('embedded.ndjson'),
);
lines is the same chunk-boundary fix as the SSE parser: keep the incomplete tail. The pipeline was run with a 7-byte highWaterMark to force splits everywhere, on 1,000 records containing "é" and "€"; all 500 kept records came out intact and in order.
What exists already, so you know when not to write your own:
| Need | Node 22 | Browser |
|---|---|---|
map / filter / take on a sync iterator | Iterator.prototype.map etc. (iterator helpers) | Recent Chrome, Firefox, Safari |
map / filter / take on an async iterator | Not built in yet (async iterator helpers are still a proposal). Use generators, or Node's Readable.prototype.map(fn, { concurrency }), .filter, .take, .toArray | Generators |
| Collect an async iterable into an array | Array.fromAsync (buffers everything) | Array.fromAsync |
| Bounded concurrent map, in order | Readable.from(src).map(fn, { concurrency: 8 }) | The build below |
Build: mapConcurrent(iterable, limit, fn)
Spec: an async generator. It pulls from iterable (sync or async), runs fn(item, index, signal) on up to limit items at once, and yields results in input order. It pulls lazily: never more than limit items ahead of what has been yielded. A rejection is thrown to the consumer at that item's turn, without crashing the process if it happens early. When the consumer breaks or an error is thrown, it closes the source (calls its return()) and aborts signal for in-flight calls.
Reference solution
export async function* mapConcurrent(iterable, limit, fn) {
if (!Number.isInteger(limit) || limit < 1) throw new RangeError('limit must be an integer >= 1');
const it = iterable[Symbol.asyncIterator]?.() ?? iterable[Symbol.iterator]();
const ac = new AbortController();
const window = []; // promises of started items, oldest first
let sourceDone = false;
let index = 0;
try {
while (true) {
// Top up: pull from the source until `limit` items are started but not yet yielded.
while (!sourceDone && window.length < limit) {
const { value, done } = await it.next();
if (done) {
sourceDone = true;
break;
}
const p = Promise.resolve().then(() => fn(value, index++, ac.signal));
p.catch(() => {}); // handled later, when its turn comes; stops an early unhandled rejection
window.push(p);
}
if (window.length === 0) return;
yield await window.shift(); // in order; a rejection is thrown here
}
} finally {
ac.abort(); // tell in-flight calls to stop (error, break, or return)
if (!sourceDone) await it.return?.(); // close the source
}
}
Tested: seven items with mixed delays come out in order with a peak of exactly 3 in flight; breaking after 3 results from a 100-item async generator pulls at most 7 items; break runs the source generator's finally and the two in-flight calls see signal.aborted; when item 3 rejects first while items 1 and 2 are still running, the consumer gets 1 and 2 and then the error, and no unhandled rejection is reported. Removing the p.catch(() => {}) line makes that last test fail. A naive version that pushes fn(x) into an array and yield awaits them in order crashes the process in the same scenario.
Edge cases interviewers check:
p.catch(() => {})does not swallow the error. It attaches a handler topso Node does not treat it as unhandled;await window.shift()still awaitspitself and throws.- Order vs throughput: in-order output means one slow item holds back faster ones behind it (head-of-line blocking). An unordered variant yields whichever finishes first, using
Promise.raceover the window with each promise tagged by its slot. Promise.resolve().then(() => fn(...))turns a synchronous throw into a rejection at the right turn.- The source is pulled one item at a time (
await it.next()), because an async iterator'snext()should not be called concurrently. finallyruns on all three exits: normal end, consumerbreak(the consumer'sfor awaitcallsreturn()), and an error thrown out of the generator.
The Causal Chains
- If you
breakout offor awaitover a generator - If you
for awaitover an array of already-started promises - If you ignore
write()returningfalse - If you use
.pipe - If an SSE parser splits each chunk on
\n - If each chunk is decoded separately
- If nothing connects the client's disconnect to the provider call
- If you listen for the disconnect on
req'close'
Review checklist
- Whole-body reads of large or unbounded data:
res.text(),res.json(),readFile,Buffer.concat,toArray,Array.fromAsync. .pipe(anywhere in server code: replace withpipelineand handle its rejection.write(in a loop without checking the return value and waiting for'drain'(with a signal).- Manual stream parsing: is there a buffer for the incomplete tail? Is there one
TextDecoderwith{ stream: true }per stream? Does it handle\r\n? - SSE consumers parse per event and check
res.okbefore streaming. - Early exits from a reader loop call
reader.cancel()(or usefor awaitwithbreak); manually driven iterators callreturn(). - Proxy routes abort the upstream call on
res'close'when!res.writableFinished, and not onreq'close'. for awaitover arrays of promises: replace withPromise.allSettledor attach handlers up front.- Hand-written concurrent iterators attach a handler to each started promise and close the source in
finally.
What's next
You now have the parts: ordering (Chapters 1–4), races and cancellation (5–6), limits and failure policy (7), the process (8) and streams (9). Chapter 10 — Reviewing a Codebase and Defending Your Designs puts them together the way the interview does: a codebase you did not write, 30 minutes, and the questions to ask out loud.