Emmanuel Akinfulubi

Events and streams all 3 written

The EventEmitter under almost everything in Node, streams, and backpressure: how to process a file bigger than your memory.

33 EventEmitter, and the error event

An event emitter is an object that announces things — "a request arrived", "a chunk of data is ready", "the process is exiting" — and lets any number of listeners react. Servers, streams, sockets and child processes in Node are all emitters, so this one class explains how most of Node talks to your code.

on and emit

Announcing, and reacting

ran at build time

example.mjs

import { EventEmitter } from "node:events";

const shop = new EventEmitter();
const log = [];

shop.on("order", (item, qty) => log.push(`email: thanks for ${qty} x ${item}`));
shop.on("order", (item) => log.push(`stock: reserve ${item}`));
shop.once("order", () => log.push("analytics: first order ever"));

shop.emit("order", "tea", 2);
shop.emit("order", "cake", 1);

console.log(log);
console.log("listeners left for 'order':", shop.listenerCount("order"));

check("every listener ran, in the order it was added", log.slice(0, 3),
      ["email: thanks for 2 x tea", "stock: reserve tea", "analytics: first order ever"]);
check("once() removed itself after the first", shop.listenerCount("order"), 2);

What node printed, when the build ran it

node example.mjs

[
  'email: thanks for 2 x tea',
  'stock: reserve tea',
  'analytics: first order ever',
  'email: thanks for 1 x cake',
  'stock: reserve cake'
]
listeners left for 'order': 2
ok   every listener ran, in the order it was added
ok   once() removed itself after the first

on(name, fn) registers a listener; emit(name, ...args) calls every listener for that name with those arguments. once registers one that removes itself after its first call, and off removes one by hand. The emitter does not know or care who is listening, which is the point: the code that takes orders does not have to know that emails, stock and analytics exist.

emit is synchronous

Listeners run inside emit

ran at build time

example.mjs

import { EventEmitter } from "node:events";

const bus = new EventEmitter();
const order = [];

bus.on("saved", () => order.push("listener ran"));

order.push("before emit");
bus.emit("saved");
order.push("after emit");

console.log(order);

check("the listener finished before emit returned", order, ["before emit", "listener ran", "after emit"]);

What node printed, when the build ran it

node example.mjs

[ 'before emit', 'listener ran', 'after emit' ]
ok   the listener finished before emit returned

"Event" sounds like "later", and it is not. emit calls each listener, right there, one after another, and returns only when they have all finished. The later part comes from whatever calls emit — a server emits "request" when the event loop hands it a new connection. A slow listener therefore delays everything after the emit, and an error thrown in a listener comes out of the emit call:

A listener that throws

ran at build time

example.mjs

import { EventEmitter } from "node:events";

const bus = new EventEmitter();
bus.on("job", () => { throw new Error("listener failed"); });

let caught;
try {
  bus.emit("job");
} catch (err) {
  caught = err.message;
}

console.log("emit threw:", caught);
console.log("emit with no listeners returns", bus.emit("nothing-listening"));

check("a listener's error surfaces from emit", caught, "listener failed");
check("emit reports whether anyone was listening", bus.emit("nothing-listening"), false);

What node printed, when the build ran it

node example.mjs

emit threw: listener failed
emit with no listeners returns false
ok   a listener's error surfaces from emit
ok   emit reports whether anyone was listening

Your own emitter

Extending EventEmitter

ran at build time

example.mjs

import { EventEmitter } from "node:events";

class Download extends EventEmitter {
  constructor(size) {
    super();
    this.size = size;
  }

  start() {
    let received = 0;
    const timer = setInterval(() => {
      received += this.size / 4;
      this.emit("progress", Math.round((received / this.size) * 100));
      if (received >= this.size) {
        clearInterval(timer);
        this.emit("done", { bytes: received });
      }
    }, 5);
    return this;
  }
}

const seen = [];
new Download(1000)
  .on("progress", (pct) => seen.push(pct))
  .on("done", (info) => {
    console.log("progress:", seen.join("% ") + "%");
    console.log("done:", info);
    check("progress was reported four times", seen, [25, 50, 75, 100]);
  })
  .start();

What node printed, when the build ran it

node example.mjs

progress: 25% 50% 75% 100%
done: { bytes: 1000 }
ok   progress was reported four times

Extending EventEmitter is the standard way to give a class of your own events. Because on returns the emitter, listeners can be chained. This is exactly how Node's own http.Server and file streams are built.

The error event is different

'error' with no listener

dies with Error

example.mjs

import { EventEmitter } from "node:events";

const connection = new EventEmitter();

// Something went wrong, and nobody is listening for errors.
connection.emit("error", new Error("connection reset by peer"));

console.log("this line never runs");

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:6
connection.emit("error", new Error("connection reset by peer"));
                         ^

Error: connection reset by peer
    at file:///app/example.mjs:6:26
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Emitted 'error' event at:
    at file:///app/example.mjs:6:12
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

Every other event with no listener is ignored. 'error' is the exception: with no listener, emit('error', err) throws err, which usually ends the process. Node does this on purpose, so that a failure cannot vanish unnoticed. The rule that follows is simple — anything that can fail, you listen to 'error' on.

Listening for errors

ran at build time

example.mjs

import { EventEmitter } from "node:events";

const connection = new EventEmitter();
const problems = [];

connection.on("error", (err) => problems.push(err.message));
connection.emit("error", new Error("connection reset by peer"));

console.log("still running; problems:", problems);

check("a listener turns a crash into a handled event", problems, ["connection reset by peer"]);

What node printed, when the build ran it

node example.mjs

still running; problems: [ 'connection reset by peer' ]
ok   a listener turns a crash into a handled event

Waiting for one event with await

events.once and events.on

ran at build time

example.mjs

import { EventEmitter, once, on } from "node:events";

const server = new EventEmitter();
setTimeout(() => server.emit("listening", 8080), 10);

const [port] = await once(server, "listening");      // a promise for the next event
console.log("listening on", port);

const feed = new EventEmitter();
setTimeout(() => ["a", "b", "c"].forEach((x) => feed.emit("item", x)), 10);

const items = [];
for await (const [item] of on(feed, "item")) {       // every event, as an async iterator
  items.push(item);
  if (items.length === 3) break;
}
console.log("items:", items);

check("once() resolves with the event's arguments", port, 8080);
check("on() turns a stream of events into a for await loop", items, ["a", "b", "c"]);

What node printed, when the build ran it

node example.mjs

listening on 8080
items: [ 'a', 'b', 'c' ]
ok   once() resolves with the event's arguments
ok   on() turns a stream of events into a for await loop

events.once(emitter, name) returns a promise for the next occurrence of an event, so it can be awaited — "wait until the server is listening, then carry on". It also rejects if the emitter emits 'error' first, which is usually what you want.

Node's leak detector

MaxListenersExceededWarning

ran at build time

example.mjs

import { EventEmitter } from "node:events";

const warnings = [];
process.on("warning", (w) => warnings.push(w.name));

const bus = new EventEmitter();

// A classic leak: adding a listener every time something happens, never removing it.
for (let request = 1; request <= 11; request++) {
  bus.on("shutdown", () => {});
}

setImmediate(() => {
  console.log("listeners now:", bus.listenerCount("shutdown"));
  check("the eleventh listener triggered Node's warning", warnings, ["MaxListenersExceededWarning"]);
});

What node printed, when the build ran it

node example.mjs

(node:<pid>) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 shutdown listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
listeners now: 11
ok   the eleventh listener triggered Node's warning

By default an emitter warns when more than ten listeners are added for the same event. It is not an error — some emitters legitimately have hundreds — but it is usually a leak: code adding a listener each time a request arrives and never removing it, so memory grows until the process is restarted. When you see this warning, find the on that runs repeatedly.

  • on adds a listener, once adds one that runs once, off removes one, emit calls them all with its arguments.
  • emit is synchronous: listeners finish before it returns, and their errors come out of it.
  • Extend EventEmitter to give a class its own events.
  • 'error' with no listener throws. Listen for it on anything that can fail.
  • events.once gives a promise; events.on gives an async iterator.
  • MaxListenersExceededWarning usually means a listener is added repeatedly and never removed.

34 Streams and pipeline

A stream is data handled a piece at a time instead of all at once. A file read as a stream arrives in chunks; a response written as a stream leaves in chunks. That lets a program work on data far larger than its memory, and start on the first piece before the last one has arrived. Files, HTTP requests and responses, sockets and child processes are all streams.

Reading in chunks

createReadStream

ran at build time

example.mjs

import { createReadStream, writeFileSync } from "node:fs";

writeFileSync("data.bin", Buffer.alloc(1024 * 1024));    // a 1 MiB file

const sizes = [];
const stream = createReadStream("data.bin");

stream.on("data", (chunk) => sizes.push(chunk.length));
stream.on("end", () => {
  console.log(`${sizes.length} chunks, each ${sizes[0]} bytes`);
  check("the file arrived as 16 chunks of 64 KiB", sizes.length, 16);
  check("which add up to the whole file", sizes.reduce((a, b) => a + b, 0), 1024 * 1024);
});

What node printed, when the build ran it

node example.mjs

16 chunks, each 65536 bytes
ok   the file arrived as 16 chunks of 64 KiB
ok   which add up to the whole file

A readable stream emits "data" for each chunk and "end" when there is no more — it is an EventEmitter (lesson 33). For files the chunk size defaults to 64 KiB, set by the highWaterMark option. Memory use stays at about one chunk however large the file is.

Streams are async iterable

for await over a stream

ran at build time

poem.txt

The fog comes
on little cat feet.
It sits looking
over harbor and city

example.mjs

import { createReadStream } from "node:fs";

let lines = 0;
let words = 0;
for await (const chunk of createReadStream("poem.txt", { encoding: "utf8", highWaterMark: 16 })) {
  lines += chunk.split("\n").length - 1;
  words += chunk.split(/\s+/).filter(Boolean).length;
}

console.log({ lines, words }, "<- the poem has 14 words");

check("every newline was counted", lines, 4);
check("but words split across chunks were counted twice", words, 17);

What node printed, when the build ran it

node example.mjs

{ lines: 4, words: 17 } <- the poem has 14 words
ok   every newline was counted
ok   but words split across chunks were counted twice

That is the simplest way to consume a readable stream: for await, from lesson 27. The tiny highWaterMark of 16 bytes forced many small chunks, and counting words per chunk is wrong whenever a chunk boundary falls inside a word — a real bug of exactly the kind lesson 30 showed for characters. Lesson 35 reads lines properly with readline. Giving the stream an encoding does make it decode as a stream, so characters are never split.

Writing

createWriteStream

ran at build time

example.mjs

import { createWriteStream } from "node:fs";
import { readFile } from "node:fs/promises";
import { once } from "node:events";

const out = createWriteStream("log.txt");

out.write("first\n");
out.write("second\n");
out.end("last\n");                    // write this, then close

await once(out, "finish");             // every byte has been handed to the OS

const text = await readFile("log.txt", "utf8");
console.log(JSON.stringify(text));

check("the writes arrived in order", text, "first\nsecond\nlast\n");

What node printed, when the build ran it

node example.mjs

"first\nsecond\nlast\n"
ok   the writes arrived in order

write queues data, end finishes the stream, and the "finish" event says everything was flushed. Reading the file before "finish" can give you half of it.

Transforming a stream

A Transform, in a pipeline

ran at build time

example.mjs

import { Transform, Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { readFile } from "node:fs/promises";

const upper = new Transform({
  transform(chunk, encoding, callback) {
    callback(null, chunk.toString().toUpperCase());   // pass on a changed chunk
  },
});

await pipeline(
  Readable.from(["streams ", "are ", "just ", "chunks\n"]),
  upper,
  createWriteStream("shout.txt"),
);

const result = await readFile("shout.txt", "utf8");
console.log(result.trim());

check("every chunk went through the transform", result, "STREAMS ARE JUST CHUNKS\n");

What node printed, when the build ran it

node example.mjs

STREAMS ARE JUST CHUNKS
ok   every chunk went through the transform

A Transform is a stream that is written to at one end and read from at the other, changing each chunk on the way. pipeline(a, b, c) connects streams so data flows from each into the next, and returns a promise that settles when the last one has finished — or when any of them fails.

Compression, which is just a transform

gzip and gunzip, streamed

ran at build time

example.mjs

import { createReadStream, createWriteStream, writeFileSync } from "node:fs";
import { stat, readFile } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { createGzip, createGunzip } from "node:zlib";

// A log file with a lot of repetition, as log files have.
const lines = Array.from({ length: 20_000 }, (_, i) => `2026-09-29 INFO request ${i % 50} served in 12ms`);
writeFileSync("access.log", lines.join("\n"));

await pipeline(createReadStream("access.log"), createGzip(), createWriteStream("access.log.gz"));
await pipeline(createReadStream("access.log.gz"), createGunzip(), createWriteStream("restored.log"));

const before = (await stat("access.log")).size;
const after = (await stat("access.log.gz")).size;
console.log(`${before} bytes -> ${after} bytes, ${(100 * after / before).toFixed(1)}% of the original`);

const same = (await readFile("access.log")).equals(await readFile("restored.log"));

check("gzip made it much smaller", after < before / 10, true);
check("and gunzip restored it exactly", same, true);

What node printed, when the build ran it

node example.mjs

835999 bytes -> 5065 bytes, 0.6% of the original
ok   gzip made it much smaller
ok   and gunzip restored it exactly

node:zlib's compressors are Transform streams, so compressing a file of any size is one line: read, gzip, write. Nothing is ever fully in memory. The same shape serves a compressed HTTP response — the server writes into a gzip stream instead of into a file.

Why pipeline, not pipe

An error in the middle of a stream

ran at build time

example.mjs

import { Readable, Writable } from "node:stream";
import { pipeline } from "node:stream/promises";

let written = 0;
const destination = new Writable({
  write(chunk, enc, callback) {
    written += chunk.length;
    callback();
  },
});

async function* source() {
  yield "row 1\n";
  yield "row 2\n";
  throw new Error("the database connection dropped");
}

let failure;
try {
  await pipeline(Readable.from(source()), destination);
} catch (err) {
  failure = err.message;
}

console.log("pipeline rejected with:", failure);
console.log("destination destroyed?", destination.destroyed, "after", written, "bytes");

check("the source's error reached the caller", failure, "the database connection dropped");
check("and pipeline cleaned up the destination", destination.destroyed, true);

What node printed, when the build ran it

node example.mjs

pipeline rejected with: the database connection dropped
destination destroyed? true after 12 bytes
ok   the source's error reached the caller
ok   and pipeline cleaned up the destination

The older way to connect streams is source.pipe(destination). It still exists, and it has a flaw: if the source fails, pipe does not tell the destination, which is left open — a file handle or socket that never closes — and the error is emitted on the source, where nothing may be listening. pipeline forwards the error to you and destroys every stream in the chain. Use it every time.

  • A stream handles data in chunks — 64 KiB at a time for files — so memory stays small.
  • Consume a readable with for await. Give it an encoding for text.
  • write then end; wait for "finish" before using the result.
  • A Transform changes chunks as they pass. zlib's gzip is one.
  • Connect streams with pipeline from node:stream/promises, which reports errors and cleans up. Not .pipe().

35 Backpressure, and a file bigger than memory

A stream can only go as fast as its slowest part. Reading a file is fast; writing to a network connection across the world may be slow. Backpressure is how the slow end tells the fast end to wait — and code that ignores it quietly holds everything in memory until the process runs out.

write() tells you when to stop

The same 8 MB, written two ways

ran at build time

example.mjs

import { Writable } from "node:stream";
import { once } from "node:events";

// A destination that takes 1 ms per chunk -- a slow disk, a slow client.
const slowDestination = () => new Writable({
  highWaterMark: 16 * 1024,
  write(chunk, encoding, callback) { setTimeout(callback, 1); },
});
const chunk = Buffer.alloc(16 * 1024);          // 16 KiB of data per write
const kib = (n) => `${(n / 1024).toFixed(0)} KiB`;

// 1. Ignoring write()'s answer.
const careless = slowDestination();
let carelessPeak = 0;
for (let i = 0; i < 500; i++) {
  careless.write(chunk);
  carelessPeak = Math.max(carelessPeak, careless.writableLength);
}
careless.end();
await once(careless, "finish");

// 2. Stopping when write() returns false, until "drain".
const careful = slowDestination();
let carefulPeak = 0;
for (let i = 0; i < 500; i++) {
  const ok = careful.write(chunk);
  carefulPeak = Math.max(carefulPeak, careful.writableLength);
  if (!ok) await once(careful, "drain");
}
careful.end();
await once(careful, "finish");

console.log("ignoring backpressure, most data waiting in memory:", kib(carelessPeak));
console.log("respecting it:                                     ", kib(carefulPeak));

check("the careless loop buffered all 500 chunks", carelessPeak, 500 * 16 * 1024);
check("the careful one never held more than one", carefulPeak <= 16 * 1024, true);

What node printed, when the build ran it

node example.mjs

ignoring backpressure, most data waiting in memory: 8000 KiB
respecting it:                                      16 KiB
ok   the careless loop buffered all 500 chunks
ok   the careful one never held more than one

write() always accepts the data, and returns false once the stream's buffer is past its highWaterMark — "I have enough, stop for now". It then emits "drain" when it has caught up. The careless loop never listened, so all 8 MB sat in memory waiting for a destination that could only take one chunk a millisecond. With 8 GB instead of 8 MB, that is a crashed process.

pipeline does it for you

Backpressure, handled by pipeline

ran at build time

example.mjs

import { Readable, Writable } from "node:stream";
import { pipeline } from "node:stream/promises";

const destination = new Writable({
  highWaterMark: 16 * 1024,
  write(chunk, encoding, callback) { setTimeout(callback, 1); },
});

let peak = 0;
const sampler = setInterval(() => { peak = Math.max(peak, destination.writableLength); }, 1);

const chunk = Buffer.alloc(16 * 1024);
await pipeline(
  Readable.from((function* () { for (let i = 0; i < 300; i++) yield chunk; })()),
  destination,
);
clearInterval(sampler);

console.log(`peak waiting in the destination: ${peak / 1024} KiB`);

check("pipeline paused the source whenever the destination was full", peak <= 32 * 1024, true);

What node printed, when the build ran it

node example.mjs

peak waiting in the destination: 16 KiB
ok   pipeline paused the source whenever the destination was full

A readable stream only produces data when something asks for it, and pipeline only asks when the next stream has room. So a pipeline from a fast source to a slow destination runs at the destination's speed with a constant, small amount of memory. This is the main reason to connect streams with pipeline rather than writing a loop.

A file bigger than you want in memory

The payoff of all of this: processing a file one line at a time, so its size does not matter. The panel writes a 40 MB log file — itself using "drain" — then counts its error lines two ways, measuring the JavaScript heap as it goes.

Counting errors in a 40 MB log

ran at build time

example.mjs

import { createReadStream, createWriteStream, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createInterface } from "node:readline";
import { once } from "node:events";

const mib = (n) => n / 1024 / 1024;

// Write 800,000 log lines, respecting backpressure.
const out = createWriteStream("access.log");
for (let i = 0; i < 800_000; i++) {
  const level = i % 7 === 0 ? "ERROR" : "INFO ";
  if (!out.write(`2026-09-29T10:00:00Z ${level} request ${i} took ${i % 500}ms\n`)) {
    await once(out, "drain");
  }
}
out.end();
await once(out, "finish");
console.log(`access.log is ${mib(statSync("access.log").size).toFixed(1)} MiB`);

// 1. One line at a time.
const samples = [];
let errors = 0;
let n = 0;
for await (const line of createInterface({ input: createReadStream("access.log"), crlfDelay: Infinity })) {
  if (line.includes(" ERROR ")) errors++;
  if (++n % 200_000 === 0) samples.push(mib(process.memoryUsage().heapUsed));
}
console.log("line by line:", errors, "errors; heap at each quarter:", samples.map((s) => s.toFixed(0) + " MiB").join(", "));

// 2. The whole file at once.
const before = process.memoryUsage().heapUsed;
const all = (await readFile("access.log", "utf8")).split("\n");
const grew = mib(process.memoryUsage().heapUsed - before);
const errors2 = all.filter((line) => line.includes(" ERROR ")).length;
console.log(`all at once:  ${errors2} errors; heap grew by ${grew.toFixed(0)} MiB`);

check("both counted the same errors", errors2, errors);
check("reading line by line kept the heap flat", Math.max(...samples) - Math.min(...samples) < 16, true);
check("reading it whole cost more than twice the file's size", grew > 2 * mib(statSync("access.log").size), true);

What node printed, when the build ran it

node example.mjs

access.log is 40.2 MiB
line by line: 114286 errors; heap at each quarter: 12 MiB, 18 MiB, 11 MiB, 16 MiB
all at once:  114286 errors; heap grew by 99 MiB
ok   both counted the same errors
ok   reading line by line kept the heap flat
ok   reading it whole cost more than twice the file's size

Read line by line, the heap was the same size at every quarter of the file: memory depends on the length of one line, not the number of lines. Read whole, the heap grew by more than twice the file's size — the file as one string, and then 800,000 separate strings from split. That is fine for 40 MB. For a 4 GB file the second approach does not run at all, and the first is unchanged.

readline, not split. readline.createInterface with a file stream yields complete lines however the chunks fall, handles \r\n endings with crlfDelay: Infinity, and decodes UTF-8 safely across chunk boundaries — every bug the hand-written chunk counter in lesson 34 had. It is the standard way to process a large text file in Node.

  • write() returns false when the destination is full. Wait for "drain" before writing more.
  • Ignoring that makes memory grow with the size of the data; respecting it keeps memory at one chunk.
  • pipeline handles backpressure automatically.
  • Read large text files with readline and for await: memory stays flat however big the file is.
  • Reading a whole file into a string costs more than its size in memory.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.