Node.js Streams skill for Codex
Flat memory for files of any size. Codex replaces readFile and Buffer.concat on unbounded input with pipeline chains whose memory stays flat at any size. Transforms are async generators, hand-written writers wait for drain, fetch bodies cross into Node with Readable.fromWeb, and a client disconnect or AbortSignal tears down every stage so cursors and file handles are released. Peak RSS is measured before and after.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- node-streams Installs to ~/.agents/skills/node-streams/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install node-streams The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project node-streams Installs to .agents/skills/node-streams/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/node-streams.zipunzip -o node-streams.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) node-streams PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $node-streams, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Processes large or unbounded data in Node.js with streams - pipeline from node:stream/promises, async generators as transforms, backpressure through write() and drain, highWaterMark and objectMode, Readable.fromWeb and toWeb for fetch bodies, AbortSignal cancellation, and memory-flat file, gzip, CSV and HTTP streaming. Use when a Node script runs out of memory on a big file, an upload or download must be proxied without buffering, .pipe() leaks or hangs on errors, or code mixes Web streams and Node streams.
Use it when
- A Node process crashes or slows down on large files or responses
- Data must be proxied, compressed or transformed without full buffering
- Errors in a .pipe() chain hang requests or leak descriptors
- Code converts between fetch Web streams and Node streams
Use something else when
- Memory grows slowly over hours rather than per job: use memory-leak-hunting
- The goal is pushing live events to browsers: use server-sent-events
How to check the result
Run the job on an input larger than the heap limit with --max-old-space-size set low, confirm peak RSS stays flat, and test that a mid-stream error rejects the pipeline and closes the sink.
The mistake it prevents
Using .pipe() or ignoring write() returning false, so errors are not propagated and unbounded data piles up in memory.
Done well means: memory stays flat no matter the input size, every stream chain is built
with pipeline so one error destroys all stages and rejects one promise, producers
respect backpressure, and cancellation (client disconnect, timeout) tears the chain down.
Before changing anything
- Read
AGENTS.mdandpackage.json(engines.node, ESM or CommonJS). The patterns here need Node 18+; Node 20+ is assumed forReadable.fromWebstability andstream.compose. - Find the current data path:
rg -n "readFileSync|readFile\(|\.pipe\(|arrayBuffer\(\)|Buffer\.concat" src. - Measure first. Run the job with a production-sized input and record peak memory:
/usr/bin/time -l node job.json macOS,/usr/bin/time -von Linux (max RSS), or logprocess.memoryUsage().rssperiodically.
Decision table
| Situation | Use |
|---|---|
| File or stream through transforms to a sink | await pipeline(src, ...transforms, sink) |
| Per-record logic (parse, filter, map) | Async generator function as a pipeline stage |
Need a reusable transform with state and flush | Transform subclass or new Transform({ transform, flush }) |
| Consume a stream in application code | for await (const chunk of readable) |
fetch response body into Node APIs | Readable.fromWeb(res.body) |
| Return a Node stream from a Web-style handler | Readable.toWeb(nodeStream) |
| Small file, known bounded size | fs.readFile is fine; do not stream for its own sake |
The default shape
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream("events.ndjson", { encoding: "utf8" }),
splitLines, // async generator stage
async function* (lines) {
for await (const line of lines) {
if (!line) continue;
const event = JSON.parse(line);
if (event.type === "purchase") yield JSON.stringify(event) + "\n";
}
},
createGzip(),
createWriteStream("purchases.ndjson.gz"),
);
async function* splitLines(source) {
let rest = "";
for await (const chunk of source) {
const parts = (rest + chunk).split("\n");
rest = parts.pop();
yield* parts;
}
if (rest) yield rest;
}
For text, pass encoding: "utf8" (or call setEncoding) so Node decodes across chunk
boundaries, or decode with a TextDecoder and { stream: true }; concatenating raw Buffer chunks as strings breaks
multi-byte characters split across chunk boundaries. For line reading alone,
readline.createInterface({ input, crlfDelay: Infinity }) is also fine.
Why pipeline and not .pipe():
.pipe()does not forward errors. If the source errors, the destination stays open and file descriptors leak; if the destination errors, the source keeps reading.pipelinedestroys every stage on failure, waits for the last stage to finish, and rejects with the first error.- Pass
{ signal }as the last argument to cancel:await pipeline(a, b, c, { signal }).
Backpressure when writing by hand
import { once } from "node:events";
async function writeAll(rows, out) {
for await (const row of rows) {
if (!out.write(serialize(row))) {
await once(out, "drain"); // buffer is above highWaterMark: wait
}
}
out.end();
await once(out, "finish");
}
Ignoring the false from write() buffers everything in memory: the classic cause of
an OOM in an export job. Better still, express the producer as an async generator and let
pipeline handle it.
highWaterMark and objectMode
| Stream type | Default highWaterMark | Unit |
|---|---|---|
| Byte streams (Node 22+) | 64 KiB | bytes |
| Byte streams (Node 20 and earlier) | 16 KiB (fs read streams 64 KiB) | bytes |
objectMode: true | 16 | objects |
- Raise it (e.g. 1 MiB) for high-throughput file copies; lower it for objects that are large.
- In objectMode the limit counts objects, not bytes: 16 rows of 10 MB each is 160 MB.
- Generators used as pipeline stages are object-mode-like; yield bounded chunks.
HTTP
Stream a large download to the client without buffering, and stop work when the client goes away:
import { pipeline } from "node:stream/promises";
server.on("request", async (req, res) => {
res.setHeader("Content-Type", "text/csv");
try {
await pipeline(queryRowsAsCsv(db, req), res); // generator yielding CSV lines
} catch (err) {
if (err.code !== "ERR_STREAM_PREMATURE_CLOSE") logger.error(err);
}
});
When the client disconnects, res closes, pipeline destroys the generator, and its
finally block runs: release database cursors there.
Proxy an upstream fetch without buffering:
const upstream = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!upstream.ok || !upstream.body) throw new Error(`upstream ${upstream.status}`);
await pipeline(Readable.fromWeb(upstream.body), res);
Uploading a Node stream with fetch requires duplex: "half" in the request init:
fetch(url, { method: "PUT", body: Readable.toWeb(fileStream), duplex: "half" }).
Writing a Transform
import { Transform } from "node:stream";
export function countBytes(onDone) {
let total = 0;
return new Transform({
transform(chunk, _enc, cb) {
total += chunk.length;
cb(null, chunk);
},
flush(cb) {
onDone(total);
cb();
},
});
}
Always call the callback exactly once per chunk, and pass errors to it (cb(err)) rather
than throwing; a thrown error inside transform escapes as an uncaught exception.
Testing
- Build inputs with
Readable.from(["a\nb", "\nc"])to hit chunk-boundary cases. - Collect output with
await Array.fromAsync(readable)(Node 22+) or afor awaitloop. - Test errors: a source that throws mid-stream must make
pipelinereject and must close the sink (assert the file handle or mock socket was destroyed). - Test memory: run with
--max-old-space-size=64against a file larger than that; a streaming implementation still passes.
Checklist
- Every chain uses
pipelinefromnode:stream/promises - No
readFile,Buffer.concatorawait res.text()on unbounded input - Hand-written producers await
drain - Cancellation wired (
signal, client disconnect) and cleanup infinally - Peak RSS measured before and after on a production-sized input
Gotchas
- Attaching a
datalistener switches a stream to flowing mode and ignores backpressure unless youpause(); prefer async iteration. - Mixing
for awaiton a stream with.on("data")on the same stream loses chunks. Readable.from("big string")orReadable.from(buffer)emits one single chunk; pass an array or generator when a test needs real chunk boundaries.zlibstreams are CPU-bound; compressing many large files in parallel saturates the libuv thread pool (default 4,UV_THREADPOOL_SIZEto change).
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Node.js Streams"
short_description: "Flat memory for files of any size"
default_prompt: "$node-streams This CSV export runs out of memory at 2 GB, rewrite it to stream" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.