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
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
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:
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
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
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.
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
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
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.
onadds a listener,onceadds one that runs once,offremoves one,emitcalls them all with its arguments.emitis synchronous: listeners finish before it returns, and their errors come out of it.- Extend
EventEmitterto give a class its own events. 'error'with no listener throws. Listen for it on anything that can fail.events.oncegives a promise;events.ongives an async iterator.MaxListenersExceededWarningusually 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
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
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
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
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
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
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 anencodingfor text. writethenend; wait for"finish"before using the result.- A Transform changes chunks as they pass.
zlib's gzip is one. - Connect streams with
pipelinefromnode: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
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
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.
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()returnsfalsewhen 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.
pipelinehandles backpressure automatically.- Read large text files with
readlineandfor await: memory stays flat however big the file is. - Reading a whole file into a string costs more than its size in memory.