Files and the process all 4 written
Reading and writing real files, bytes and encodings, and the process your program runs as: its arguments, environment, exit code and signals.
29 Reading and writing files
Reading and writing files is the first thing a browser cannot do and Node
can. node:fs/promises has everything: read, write, append, list, move, copy, delete. This
lesson uses each one on real files, and covers the three mistakes that turn up in almost every
codebase.
Read and write
example.mjs
import { writeFile, readFile, appendFile, readdir } from "node:fs/promises";
await writeFile("notes.txt", "first line\n"); // creates, or replaces
await appendFile("notes.txt", "second line\n"); // adds to the end
const text = await readFile("notes.txt", "utf8"); // the encoding makes it a string
const raw = await readFile("notes.txt"); // no encoding: raw bytes
console.log(JSON.stringify(text));
console.log(raw);
console.log(await readdir("."));
check("appendFile added, writeFile replaced", text, "first line\nsecond line\n");
check("without an encoding you get a Buffer of bytes", Buffer.isBuffer(raw), true);
What node printed, when the build ran it
node example.mjs
"first line\nsecond line\n" <Buffer 66 69 72 73 74 20 6c 69 6e 65 0a 73 65 63 6f 6e 64 20 6c 69 6e 65 0a> [ 'example.mjs', 'notes.txt' ] ok appendFile added, writeFile replaced ok without an encoding you get a Buffer of bytes
Always pass an encoding — almost always "utf8" — when you want text. Without one you
get a Buffer, the raw bytes of the file, which is what lesson 30 is about. And note that
writeFile replaces a file's contents without asking; there is no undo.
Folders
example.mjs
import { mkdir, writeFile, readdir, rename, copyFile, rm } from "node:fs/promises";
await mkdir("reports/2026/q3", { recursive: true }); // makes every missing parent
await writeFile("reports/2026/q3/summary.txt", "up 4%");
await copyFile("reports/2026/q3/summary.txt", "reports/latest.txt");
await rename("reports/latest.txt", "reports/current.txt");
const tree = (await readdir("reports", { recursive: true })).sort();
console.log(tree);
const entries = await readdir("reports", { withFileTypes: true });
for (const e of entries) console.log(e.isDirectory() ? "dir " : "file", e.name);
await rm("reports", { recursive: true, force: true });
const afterRm = await readdir(".");
console.log(afterRm);
check("mkdir made every level, and the copy was renamed",
tree, ["2026", "2026/q3", "2026/q3/summary.txt", "current.txt"]);
check("rm recursive removed the whole tree", afterRm, ["example.mjs"]);
What node printed, when the build ran it
node example.mjs
[ '2026', '2026/q3', '2026/q3/summary.txt', 'current.txt' ] dir 2026 file current.txt [ 'example.mjs' ] ok mkdir made every level, and the copy was renamed ok rm recursive removed the whole tree
Finding files by pattern
src/app.mjs
src/lib/db.mjs
src/lib/util/strings.mjs
src/lib/README.md
test/app.test.mjs
example.mjs
import { glob } from "node:fs/promises";
const found = [];
for await (const path of glob("src/**/*.mjs")) found.push(path);
console.log(found.sort());
check("glob matched .mjs files at every depth, and nothing else",
found.sort(), ["src/app.mjs", "src/lib/db.mjs", "src/lib/util/strings.mjs"]);
What node printed, when the build ran it
node example.mjs
[ 'src/app.mjs', 'src/lib/db.mjs', 'src/lib/util/strings.mjs' ] ok glob matched .mjs files at every depth, and nothing else
** means "any number of folders", and * "any characters in one name".
glob is recent in Node; before it, every project installed a package to do this.
Mistake one: checking before you act
present.txt
here
example.mjs
import { readFile, access } from "node:fs/promises";
// The tempting version: two steps, and a gap between them.
async function loadRisky(path) {
try {
await access(path); // does it exist?
} catch {
return "(no file)";
}
// ...another process could delete it right here...
return readFile(path, "utf8");
}
// The right version: do the thing, and handle the failure you expect.
async function load(path) {
try {
return await readFile(path, "utf8");
} catch (err) {
if (err.code === "ENOENT") return "(no file)";
throw err;
}
}
console.log(await load("settings.txt"), (await load("present.txt")).trim());
check("a missing file is an expected outcome, not a crash", await load("settings.txt"), "(no file)");
check("and a present one is read", (await load("present.txt")).trim(), "here");
What node printed, when the build ran it
node example.mjs
(no file) here ok a missing file is an expected outcome, not a crash ok and a present one is read
Checking that a file exists and then opening it is two separate trips to the operating system, and
anything can happen between them — another process deletes it, or creates it. That gap is a real bug
class with a name, time-of-check to time-of-use. There is no way to close it from JavaScript.
Doing the operation you actually want and catching ENOENT has no gap, and is less code.
Mistake two: a half-written file
example.mjs
import { writeFile, readFile, rename } from "node:fs/promises";
const config = { theme: "dark", fontSize: 14, plugins: ["git", "lint"] };
// Write the new contents to a temporary file beside the real one, then rename.
async function saveAtomically(path, text) {
const temp = `${path}.${process.pid}.tmp`;
await writeFile(temp, text);
await rename(temp, path); // a rename replaces the file in one step
}
await saveAtomically("config.json", JSON.stringify(config, null, 2));
const back = JSON.parse(await readFile("config.json", "utf8"));
console.log(back);
check("the whole file arrived", back, config);
What node printed, when the build ran it
node example.mjs
{ theme: 'dark', fontSize: 14, plugins: [ 'git', 'lint' ] }
ok the whole file arrived
writeFile empties the file and then writes into it. If the process crashes, or the disk
fills, partway through, the file is left truncated — and a config file cut off halfway is not valid
JSON, so the program will not even start next time. A rename within one file system happens in a
single step: readers see either the whole old file or the whole new one, never half. Any file your
program must be able to read back should be saved this way.
Mistake three: holding a file open
example.mjs
import { open } from "node:fs/promises";
let handle;
{
await using file = await open("log.txt", "w"); // closed automatically at the end of the block
handle = file;
await file.write("started\n");
await file.write("finished\n");
}
let closedError;
try {
await handle.write("too late\n");
} catch (err) {
closedError = err.code;
}
console.log("writing after the block ended:", closedError);
check("using closed the file when the block ended", closedError, "EBADF");
What node printed, when the build ran it
node example.mjs
writing after the block ended: EBADF ok using closed the file when the block ended
For many writes to one file, open gives you a file handle, which must be
closed when you are done — the operating system only lets a process hold a limited number open, and a
server that leaks one per request eventually fails with EMFILE. await using
closes it automatically when the block ends, however the block ends, which is exactly the job
finally was doing by hand.
Sync or async?
example.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
writeFileSync("big.txt", "x".repeat(20_000_000)); // 20 MB
let ticks = 0;
const ticker = setInterval(() => ticks++, 1);
let t = performance.now();
readFileSync("big.txt");
const syncMs = performance.now() - t;
const ticksDuringSync = ticks;
t = performance.now();
await readFile("big.txt");
const asyncMs = performance.now() - t;
const ticksDuringAsync = ticks - ticksDuringSync;
clearInterval(ticker);
console.log(`readFileSync: ${syncMs.toFixed(1)} ms, timer ran ${ticksDuringSync} times`);
console.log(`readFile: ${asyncMs.toFixed(1)} ms, timer ran ${ticksDuringAsync} times`);
check("nothing else could run during the sync read", ticksDuringSync, 0);
check("while other work kept running during the async one", ticksDuringAsync > 0, true);
check("and the sync read was the faster of the two", syncMs < asyncMs, true);
What node printed, when the build ran it
node example.mjs
readFileSync: 3.5 ms, timer ran 0 times readFile: 14.9 ms, timer ran 15 times ok nothing else could run during the sync read ok while other work kept running during the async one ok and the sync read was the faster of the two
Look at both numbers. The sync read was faster — there is less machinery between your code
and the disk — and the timer did not run once while it happened. The async read took longer, and the
timer kept running the whole time. That is the trade: the Sync versions block, and
nothing else can run until they return — not a timer, not a request. That is fine in a script, or while a server starts up, and they are simpler. Inside anything
that serves requests, use the promise versions, so one slow disk read does not stall every other
user.
reports/q3.txt
up 4%
example.mjs
import { readFile } from "node:fs/promises";
const text = await readFile("reports", "utf8");
What node printed as it died, when the build ran it
node example.mjs
node:internal/fs/promises:1261
const bytesRead = (await PromisePrototypeThen(
^
Error: EISDIR: illegal operation on a directory, read
at async readFileHandle (node:internal/fs/promises:1261:24)
at async file:///app/example.mjs:3:14 {
errno: -21,
code: 'EISDIR',
syscall: 'read'
}
Node.js v26.7.0
- Use
node:fs/promises. Pass"utf8"to get text; leave it off to get bytes. mkdirandrmtake{ recursive: true };readdircan recurse and report types;globfinds by pattern.- Do not check then act. Attempt the operation and handle
ENOENT. - Save important files by writing a temporary file and renaming it over the original.
- Close what you open;
await usingdoes it for you. - Sync calls block everything. Fine in scripts and at startup; not in a server.
30 Paths, Buffers and encodings
Files have names, and names have rules that differ between operating systems. Files hold bytes, and bytes only become text when you say which encoding they are in. Both are sources of bugs that work on your machine and fail on someone else's, and Node has a module for each.
Building paths
example.mjs
import path from "node:path";
const file = path.join("reports", "2026", "..", "2025", "q3.csv");
console.log(file);
console.log(path.dirname(file), path.basename(file), path.extname(file));
console.log(path.basename(file, ".csv"));
console.log(path.parse(file));
console.log(path.resolve("reports", "q3.csv")); // made absolute, from the current folder
console.log(path.relative("/app/src/lib", "/app/assets/logo.png"));
check("join cleans up .. segments", file, "reports/2025/q3.csv");
check("extname includes the dot", path.extname(file), ".csv");
check("relative gives the way from one to the other", path.relative("/a/b/c", "/a/d"), "../../d");
What node printed, when the build ran it
node example.mjs
reports/2025/q3.csv
reports/2025 q3.csv .csv
q3
{
root: '',
dir: 'reports/2025',
base: 'q3.csv',
ext: '.csv',
name: 'q3'
}
/app/reports/q3.csv
../../assets/logo.png
ok join cleans up .. segments
ok extname includes the dot
ok relative gives the way from one to the other
Build paths with path.join, never by gluing strings with "/". Windows
separates folders with a backslash, and path knows which rules apply on the machine it
is running on — and can be asked for the other set explicitly:
example.mjs
import path from "node:path";
console.log("posix:", path.posix.join("C:", "Users", "ada", "notes.txt"));
console.log("win32:", path.win32.join("C:", "Users", "ada", "notes.txt"));
console.log("this machine uses:", JSON.stringify(path.sep));
check("the same call, Windows rules", path.win32.join("a", "b"), "a\\b");
check("and POSIX rules", path.posix.join("a", "b"), "a/b");
What node printed, when the build ran it
node example.mjs
posix: C:/Users/ada/notes.txt win32: C:\Users\ada\notes.txt this machine uses: "/" ok the same call, Windows rules ok and POSIX rules
Paths and file: URLs
example.mjs
import { fileURLToPath, pathToFileURL } from "node:url";
const url = pathToFileURL("/app/My Documents/café.txt");
console.log(url.href);
console.log(fileURLToPath(url));
check("spaces and accents are encoded in a URL", url.href, "file:///app/My%20Documents/caf%C3%A9.txt");
check("and decoded back to a path", fileURLToPath(url), "/app/My Documents/café.txt");
What node printed, when the build ran it
node example.mjs
file:///app/My%20Documents/caf%C3%A9.txt /app/My Documents/café.txt ok spaces and accents are encoded in a URL ok and decoded back to a path
ES modules identify files by URL — that is what import.meta.url is — while the
file-system functions take paths. Converting by chopping "file://" off the front works
until a folder name contains a space, which the URL spells %20. Use
fileURLToPath, or better, import.meta.dirname (lesson 20), which is already a
path.
Text is bytes, in an encoding
example.mjs
const words = ["hello", "héllo", "👍"];
for (const w of words) {
const bytes = Buffer.from(w, "utf8");
console.log(w.padEnd(6), "length", w.length, " bytes", bytes.length, " ", bytes);
}
check("an accented letter is 2 bytes in UTF-8", Buffer.byteLength("é"), 2);
check("an emoji is 4", Buffer.byteLength("👍"), 4);
check("so length is not size", "héllo".length === Buffer.byteLength("héllo"), false);
What node printed, when the build ran it
node example.mjs
hello length 5 bytes 5 <Buffer 68 65 6c 6c 6f> héllo length 5 bytes 6 <Buffer 68 c3 a9 6c 6c 6f> 👍 length 2 bytes 4 <Buffer f0 9f 91 8d> ok an accented letter is 2 bytes in UTF-8 ok an emoji is 4 ok so length is not size
A Buffer is a fixed-size run of bytes — Node's version of a
Uint8Array. Text becomes bytes through an encoding, and the one that
matters is UTF-8: plain ASCII letters take one byte each, accented letters two, most
other scripts three, and emoji four. So a string's length is not its size on disk or on
the wire, and a database column limited to 255 bytes fits fewer than 255 characters of
anything but English.
example.mjs
const bytes = Buffer.from("café", "utf8");
console.log("as UTF-8: ", bytes.toString("utf8"));
console.log("as latin1: ", bytes.toString("latin1"), "<- the right bytes, read with the wrong encoding");
console.log("as hex: ", bytes.toString("hex"));
console.log("as base64: ", bytes.toString("base64"));
console.log(Buffer.from("Y2Fmw6k=", "base64").toString("utf8"));
check("decoding with the wrong encoding garbles it", bytes.toString("latin1"), "café");
check("base64 round-trips", Buffer.from(bytes.toString("base64"), "base64").equals(bytes), true);
What node printed, when the build ran it
node example.mjs
as UTF-8: café as latin1: café <- the right bytes, read with the wrong encoding as hex: 636166c3a9 as base64: Y2Fmw6k= café ok decoding with the wrong encoding garbles it ok base64 round-trips
That café is called mojibake, and you have seen it on web pages and in
emails: UTF-8 bytes interpreted as a different encoding. Nothing in the bytes says what encoding they
are in. Whoever reads them has to know — which is why files, HTTP responses and databases all carry
an encoding label somewhere, and why "utf8" belongs on every readFile of
text.
The bug in reading text in pieces
Files and network data often arrive in chunks, split wherever the buffer happened to fill up — including in the middle of a character.
example.mjs
const bytes = Buffer.from("café au lait", "utf8");
// Split after the fourth byte: in the middle of é, which is two bytes.
const chunks = [bytes.subarray(0, 4), bytes.subarray(4)];
console.log(chunks);
// Decoding each chunk separately breaks the character.
const naive = chunks.map((c) => c.toString("utf8")).join("");
console.log("each chunk decoded alone:", naive);
// A decoder that remembers an incomplete character between chunks.
const decoder = new TextDecoder("utf-8");
const careful = chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode();
console.log("decoded as a stream: ", careful);
check("naive decoding produced replacement characters", naive.includes("\uFFFD"), true);
check("streaming decoding did not", careful, "café au lait");
What node printed, when the build ran it
node example.mjs
[ <Buffer 63 61 66 c3>, <Buffer a9 20 61 75 20 6c 61 69 74> ] each chunk decoded alone: caf�� au lait decoded as a stream: café au lait ok naive decoding produced replacement characters ok streaming decoding did not
Each half of the é on its own is not valid UTF-8, so decoding the chunks separately
turns each half into �, the replacement character — two of them where one letter should
be. It is intermittent in real life — it only
happens when a chunk boundary falls inside a multi-byte character, so it shows up with accented names
and emoji, and never in a test that uses English. Decode with a TextDecoder and
{ stream: true }, or let Node do it: give a stream an encoding (lesson 34) and it uses the
same technique.
Buffers as binary data
example.mjs
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
// A one-pixel PNG, written from base64 so the panel can show every byte of it.
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
await writeFile("pixel.png", Buffer.from(base64, "base64"));
const png = await readFile("pixel.png");
console.log("file size:", png.length, "bytes");
console.log("first 8 bytes:", png.subarray(0, 8));
console.log("is it a PNG?", png.subarray(1, 4).toString("ascii"));
console.log("sha256:", createHash("sha256").update(png).digest("hex").slice(0, 16) + "...");
const header = Buffer.alloc(4);
header.writeUInt32BE(1024);
console.log("1024 as four big-endian bytes:", header);
check("the PNG signature", [...png.subarray(0, 4)], [0x89, 0x50, 0x4e, 0x47]);
check("reading the number back", header.readUInt32BE(), 1024);
What node printed, when the build ran it
node example.mjs
file size: 70 bytes first 8 bytes: <Buffer 89 50 4e 47 0d 0a 1a 0a> is it a PNG? PNG sha256: c414cd0e204de974... 1024 as four big-endian bytes: <Buffer 00 00 04 00> ok the PNG signature ok reading the number back
- Build paths with
path.join; it knows the separator.path.win32andpath.posixapply either set of rules anywhere. - Convert URLs and paths with
fileURLToPathandpathToFileURL, never by slicing strings. - A string's
lengthis not its size in bytes.Buffer.byteLengthis. - Bytes do not say their encoding. Reading UTF-8 as anything else produces mojibake.
- Text split into chunks must be decoded as a stream, or characters that straddle a boundary break.
31 The process: arguments, environment, exit codes and signals
A running Node program is a process, like every other
program on the machine. It receives arguments and an environment when it starts, reads and writes
text while it runs, and reports an exit code when it stops. The global process object
is how your code reaches all of them.
Arguments
example.mjs
import { basename } from "node:path";
console.log(basename(process.argv[0]), "<- the node program");
console.log(basename(process.argv[1]), "<- your file");
console.log(process.argv.slice(2), "<- everything you typed after it");
check("arguments are the words after the file name", process.argv.slice(2), ["report.csv", "--limit", "10", "two words"]);
What node printed, when the build ran it
node example.mjs report.csv --limit 10 'two words'
node <- the node program example.mjs <- your file [ 'report.csv', '--limit', '10', 'two words' ] <- everything you typed after it ok arguments are the words after the file name
process.argv is every word on the command line, as strings. The first two are always
Node itself and your script, so your arguments start at index 2. The shell does the splitting, which
is why the quoted 'two words' arrived as one argument.
example.mjs
import { parseArgs } from "node:util";
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
limit: { type: "string", short: "n", default: "20" },
verbose: { type: "boolean", short: "v" },
},
allowPositionals: true,
});
console.log(values, positionals);
const limit = Number(values.limit);
check("flags were parsed, with a short form", values.verbose, true);
check("values arrive as strings, so convert them", limit, 5);
check("everything else is a positional argument", positionals, ["sales.csv"]);
What node printed, when the build ran it
node example.mjs sales.csv -v --limit 5
[Object: null prototype] { verbose: true, limit: '5' } [ 'sales.csv' ]
ok flags were parsed, with a short form
ok values arrive as strings, so convert them
ok everything else is a positional argument
example.mjs
import { parseArgs } from "node:util";
try {
parseArgs({ args: process.argv.slice(2), options: { limit: { type: "string" } } });
} catch (err) {
console.error(`usage error: ${err.message}`);
process.exitCode = 2;
}
check("an unknown flag is rejected, not ignored", process.exitCode, 2);
What node printed, when the build ran it
node example.mjs --limt 5
usage error: Unknown option '--limt' ok an unknown flag is rejected, not ignored
parseArgs is built into Node and handles the common cases — long and short flags,
booleans, defaults, and a clear error for a flag it does not know, so a typo like --limt
is reported instead of silently ignored. Lesson 50 builds a whole command-line tool on it.
The environment
example.mjs
const port = process.env.PORT;
const debug = process.env.DEBUG;
const missing = process.env.NOT_SET;
console.log({ port, debug, missing });
console.log(typeof port, "<- always a string");
if (debug) console.log("debug is on?! DEBUG was", JSON.stringify(debug));
const debugOn = process.env.DEBUG === "true";
const portNumber = Number(process.env.PORT ?? 3000);
check("an unset variable is undefined", missing, undefined);
check("the string 'false' is truthy", Boolean(debug), true);
check("so compare against the exact string", debugOn, false);
check("and convert numbers yourself", portNumber, 4000);
What node printed, when the build ran it
PORT=4000 DEBUG=false node example.mjs
{ port: '4000', debug: 'false', missing: undefined }
string <- always a string
debug is on?! DEBUG was "false"
ok an unset variable is undefined
ok the string 'false' is truthy
ok so compare against the exact string
ok and convert numbers yourself
Environment variables are how a program is configured without changing its code — the port to
listen on, the database to connect to, whether debugging is on. Every one is a string, or
undefined if it was never set. That makes DEBUG=false a trap: the string
"false" is truthy (lesson 7), so if (process.env.DEBUG) is true. Compare with
the exact string you expect, and convert numbers explicitly.
Loading a .env file
.env
# Local settings. Never commit real secrets.
DATABASE_URL=postgres://localhost:5432/dev
API_KEY=dev-key-not-a-secret
show.mjs
console.log("DATABASE_URL:", process.env.DATABASE_URL);
console.log("API_KEY: ", process.env.API_KEY);
The terminal, as the build ran it
cat .env # Local settings. Never commit real secrets. DATABASE_URL=postgres://localhost:5432/dev API_KEY=dev-key-not-a-secret node --env-file=.env show.mjs DATABASE_URL: postgres://localhost:5432/dev API_KEY: dev-key-not-a-secret node show.mjs DATABASE_URL: undefined API_KEY: undefined
A .env file holds settings for your own machine, one NAME=value per line.
Node reads one natively with --env-file (or process.loadEnvFile() from code),
which used to need a package. The second run shows the point: without the flag, the program sees
nothing. Keep .env out of git — lesson 48 covers configuration and secrets properly.
Standard input
sales.csv
region,amount
north,10
south,20
east,30
example.mjs
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const lines = Buffer.concat(chunks).toString("utf8").trim().split("\n");
const total = lines.slice(1).reduce((sum, line) => sum + Number(line.split(",")[1]), 0);
console.log(`${lines.length - 1} rows, total ${total}`);
check("read every line from standard input", lines.length, 4);
check("and added up the second column", total, 60);
What node printed, when the build ran it
node example.mjs < sales.csv
3 rows, total 60 ok read every line from standard input ok and added up the second column
sales.csv
north,10
south,20
north,30
total.mjs
let total = 0;
for await (const chunk of process.stdin) {
for (const line of chunk.toString("utf8").trim().split("\n")) total += Number(line.split(",")[1]);
}
console.log("total:", total);
The terminal, as the build ran it
cat sales.csv | node total.mjs total: 60 node total.mjs < sales.csv total: 60 grep north sales.csv | node total.mjs total: 40
A program that reads standard input and writes standard output can be combined with any other
program with a |: here, grep picked the rows and Node added them up. That is
the oldest and most useful idea in command-line software, and every Node program gets it for free.
Exit codes
When a process ends it reports a number to whatever started it. Zero means success; anything else means failure, and scripts, CI systems and shells all act on it.
ok.mjs
console.log("all good");
fails.mjs
console.error("input file was empty");
process.exitCode = 3;
crashes.mjs
null.boom;
The terminal, as the build ran it
node ok.mjs; echo "exit code: $?" all good exit code: 0 node fails.mjs; echo "exit code: $?" input file was empty exit code: 3 node crashes.mjs 2> /dev/null; echo "exit code: $?" exit code: 1 node fails.mjs && echo "this is skipped, because it failed" # exits 3 input file was empty
An uncaught error exits with 1, which is why every crash in this course could be detected by the
build. For an expected failure, set process.exitCode and let the program finish
normally. && in a shell only runs the next command if the first succeeded, which is
how CI pipelines stop at the first failing step.
exit-now.mjs
import { writeFile } from "node:fs/promises";
writeFile("report.txt", "x".repeat(10_000_000));
process.exit(0); // ends the process now
exit-code.mjs
import { writeFile } from "node:fs/promises";
writeFile("report.txt", "x".repeat(10_000_000));
process.exitCode = 0; // records the result, and lets pending work finish
The terminal, as the build ran it
node exit-now.mjs test "$(wc -c < report.txt)" -eq 0 && echo "report.txt is empty: the write never happened" report.txt is empty: the write never happened node exit-code.mjs test "$(wc -c < report.txt)" -eq 10000000 && echo "report.txt has all 10,000,000 bytes" report.txt has all 10,000,000 bytes
The test lines are the shell's own assertions: each exits non-zero if its comparison
is false, and this site refuses to build if a command exits non-zero without saying so. So the page
cannot claim the first file was empty unless it was.
process.exit() stops the process immediately — pending writes, timers and network
requests are simply abandoned. Prefer process.exitCode = n, which sets the code and lets
Node exit on its own once there is nothing left to do.
Signals
example.mjs
const log = [];
process.on("SIGTERM", () => {
log.push("received SIGTERM: finishing the current job, then stopping");
clearInterval(work);
});
process.on("exit", (code) => {
console.log(log.join("\n"));
console.log("exiting with code", code);
check("the handler ran before the process ended", log.length, 1);
});
const work = setInterval(() => {}, 1000); // stands in for a server that never stops by itself
setTimeout(() => process.kill(process.pid, "SIGTERM"), 20); // what "stop" sends
What node printed, when the build ran it
node example.mjs
received SIGTERM: finishing the current job, then stopping exiting with code 0 ok the handler ran before the process ended
A signal is how the operating system, or another program, asks a process to do
something — usually to stop. SIGINT is what Ctrl+C sends; SIGTERM is what
servers, containers and process managers send when they want you to shut down. Without a handler,
either one ends the process on the spot. With one, you get the chance to finish in-flight work first
— lesson 48 builds a proper graceful shutdown on exactly this.
- Your arguments start at
process.argv[2].util.parseArgsparses flags and rejects unknown ones. - Every environment variable is a string or
undefined. Compare exactly; convert numbers. node --env-file=.envloads a settings file; keep it out of git.- Read piped input with
for await (const chunk of process.stdin). - Exit code 0 is success. Set
process.exitCoderather than callingprocess.exit(). - Handle
SIGTERMandSIGINTto stop cleanly.
32 Running other programs
Sometimes the best tool for a job is another program — git, an
image converter, a script in another language. node:child_process starts other programs,
passes them arguments, reads what they print and learns how they exited. It is also one of the
easiest places in Node to introduce a security hole.
Run a program and get its output
data/a.csv
data/b.csv
example.mjs
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const { stdout } = await run("ls", ["-1", "data"]);
console.log(stdout.trim().split("\n"));
const node = await run(process.execPath, ["-p", "6 * 7"]); // Node itself, as a child
console.log("the child node printed:", node.stdout.trim());
check("ls listed the folder", stdout.trim().split("\n"), ["a.csv", "b.csv"]);
check("and a child node process computed 42", node.stdout.trim(), "42");
What node printed, when the build ran it
node example.mjs
[ 'a.csv', 'b.csv' ] the child node printed: 42 ok ls listed the folder ok and a child node process computed 42
execFile runs a program with a list of arguments, waits for it to finish, and gives you
everything it printed. process.execPath is the full path of the node running
your code, which is the reliable way to start another Node process.
Failures
example.mjs
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
async function attempt(file, args) {
try {
await run(file, args);
return "succeeded";
} catch (err) {
return `failed: code ${err.code}${err.stderr ? `, stderr "${err.stderr.trim()}"` : ""}`;
}
}
console.log(await attempt("sh", ["-c", "echo 'disk full' >&2; exit 3"]));
console.log(await attempt("no-such-program", []));
check("a non-zero exit becomes a rejection carrying the code", await attempt("sh", ["-c", "exit 3"]), "failed: code 3");
check("a missing program is ENOENT", await attempt("no-such-program", []), "failed: code ENOENT");
What node printed, when the build ran it
node example.mjs
failed: code 3, stderr "disk full" failed: code ENOENT ok a non-zero exit becomes a rejection carrying the code ok a missing program is ENOENT
When the program exits with anything but 0, the promise rejects, and the error carries the exit
code as err.code along with what the program wrote to standard error. When the program
does not exist at all, err.code is the string "ENOENT" — the same "no such
file" code as a missing file, because that is exactly what it is.
Never build a command string from input
exec is execFile's sibling: it takes a whole command line as one string
and runs it through a shell. That is convenient, and it means the shell interprets everything in the
string — including anything a user typed.
example.mjs
import { exec, execFile } from "node:child_process";
import { promisify } from "node:util";
const shell = promisify(exec);
const direct = promisify(execFile);
// A "filename" typed by a user. The ; ends one command and starts another.
const userInput = "report.txt; echo INJECTED: this command was never meant to run";
const viaShell = await shell(`echo processing ${userInput}`);
const viaFile = await direct("echo", ["processing", userInput]);
console.log("exec (a shell):");
console.log(viaShell.stdout.trim());
console.log();
console.log("execFile (no shell):");
console.log(viaFile.stdout.trim());
check("through a shell, the user's text ran as a second command",
viaShell.stdout.trim().split("\n").at(-1), "INJECTED: this command was never meant to run");
check("without one, it was just an argument", viaFile.stdout.trim().split("\n").length, 1);
What node printed, when the build ran it
node example.mjs
exec (a shell): processing report.txt INJECTED: this command was never meant to run execFile (no shell): processing report.txt; echo INJECTED: this command was never meant to run ok through a shell, the user's text ran as a second command ok without one, it was just an argument
Through exec, the ; in the "filename" ended the echo and the
rest ran as a second command. Here it only printed a line; in a real attack it would be
rm -rf, or a download-and-run. execFile passes each argument straight to the
program with no shell in between, so the same text is just a strange filename. Use
execFile or spawn with an argument array, always, and reach for
exec only for fixed command strings you wrote yourself.
Streaming output: spawn
example.mjs
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", `
let n = 0;
const t = setInterval(() => {
console.log("progress", ++n * 25 + "%");
if (n === 4) clearInterval(t);
}, 10);
`]);
const seen = [];
child.stdout.on("data", (chunk) => {
for (const line of chunk.toString().trim().split("\n")) {
seen.push(line);
console.log("parent received:", line);
}
});
child.on("close", (code) => {
console.log("child exited with", code);
check("each line arrived while the child was still running", seen.length, 4);
check("and it exited cleanly", code, 0);
});
What node printed, when the build ran it
node example.mjs
parent received: progress 25% parent received: progress 50% parent received: progress 75% parent received: progress 100% child exited with 0 ok each line arrived while the child was still running ok and it exited cleanly
execFile waits and hands you everything at the end, buffered in memory, up to a size
limit. spawn gives you the child's output as a stream while it runs (streams are
lesson 34), which is what you want for anything long-running or large: a build, a video encode, a
log you are following.
Stopping a child that takes too long
example.mjs
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const started = performance.now();
let outcome;
try {
await run("sleep", ["5"], { timeout: 50 });
outcome = "finished";
} catch (err) {
outcome = `killed by ${err.signal} after ${Math.round(performance.now() - started)} ms`;
}
console.log(outcome);
check("the timeout killed it", outcome.startsWith("killed by SIGTERM"), true);
What node printed, when the build ran it
node example.mjs
killed by SIGTERM after 55 ms ok the timeout killed it
Talking to a child Node process: fork
worker.mjs
process.on("message", ({ task, n }) => {
if (task === "square") process.send({ n, result: n * n, pid: process.pid });
});
example.mjs
import { fork } from "node:child_process";
const worker = fork("worker.mjs");
const answers = [];
worker.on("message", (msg) => {
answers.push(msg);
console.log("parent got:", { n: msg.n, result: msg.result });
if (answers.length === 2) worker.disconnect();
});
worker.send({ task: "square", n: 7 });
worker.send({ task: "square", n: 12 });
worker.on("exit", () => {
check("the child answered both messages", answers.map((a) => a.result), [49, 144]);
check("from its own process", answers[0].pid !== process.pid, true);
});
What node printed, when the build ran it
node example.mjs
parent got: { n: 7, result: 49 }
parent got: { n: 12, result: 144 }
ok the child answered both messages
ok from its own process
fork starts another Node process running a given file, with a message channel between
the two: send on one side arrives as a "message" event on the other. The
child has its own memory and its own event loop, so heavy work there cannot block the parent. For
heavy work inside one process, lesson 44's worker threads are usually the lighter choice.
execFileruns a program with an argument array and gives you its output at the end.- A non-zero exit rejects with
err.codeset to the exit code; a missing program givesENOENT. execruns a string through a shell. Never put input into it; useexecFileorspawnwith an array.spawnstreams output as the child runs. Give long-running children atimeoutor asignal.forkstarts a Node child with a message channel.