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.

  • Backend & APIs
  • $node-streams
  • 192 lines
  • 2 files
  • ~1,750 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- node-streams

Installs to ~/.agents/skills/node-streams/ for every project on this machine.

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

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.

~/.agents/skills/node-streams/SKILL.md

--- name: node-streams description: Processes large or unbounded data in Node.js with streams - pipeline from node:stream/promises, async generators as transforms, backpressure through… license: MIT ---

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

  1. Read AGENTS.md and package.json (engines.node, ESM or CommonJS). The patterns here need Node 18+; Node 20+ is assumed for Readable.fromWeb stability and stream.compose.
  2. Find the current data path: rg -n "readFileSync|readFile\(|\.pipe\(|arrayBuffer\(\)|Buffer\.concat" src.
  3. Measure first. Run the job with a production-sized input and record peak memory: /usr/bin/time -l node job.js on macOS, /usr/bin/time -v on Linux (max RSS), or log process.memoryUsage().rss periodically.

Decision table

SituationUse
File or stream through transforms to a sinkawait pipeline(src, ...transforms, sink)
Per-record logic (parse, filter, map)Async generator function as a pipeline stage
Need a reusable transform with state and flushTransform subclass or new Transform({ transform, flush })
Consume a stream in application codefor await (const chunk of readable)
fetch response body into Node APIsReadable.fromWeb(res.body)
Return a Node stream from a Web-style handlerReadable.toWeb(nodeStream)
Small file, known bounded sizefs.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.
  • pipeline destroys 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 typeDefault highWaterMarkUnit
Byte streams (Node 22+)64 KiBbytes
Byte streams (Node 20 and earlier)16 KiB (fs read streams 64 KiB)bytes
objectMode: true16objects
  • 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 a for await loop.
  • Test errors: a source that throws mid-stream must make pipeline reject and must close the sink (assert the file handle or mock socket was destroyed).
  • Test memory: run with --max-old-space-size=64 against a file larger than that; a streaming implementation still passes.

Checklist

  • Every chain uses pipeline from node:stream/promises
  • No readFile, Buffer.concat or await res.text() on unbounded input
  • Hand-written producers await drain
  • Cancellation wired (signal, client disconnect) and cleanup in finally
  • Peak RSS measured before and after on a production-sized input

Gotchas

  • Attaching a data listener switches a stream to flowing mode and ignores backpressure unless you pause(); prefer async iteration.
  • Mixing for await on a stream with .on("data") on the same stream loses chunks.
  • Readable.from("big string") or Readable.from(buffer) emits one single chunk; pass an array or generator when a test needs real chunk boundaries.
  • zlib streams are CPU-bound; compressing many large files in parallel saturates the libuv thread pool (default 4, UV_THREADPOOL_SIZE to change).

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem