Emmanuel Akinfulubi

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

writeFile, appendFile, readFile

ran at build time

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

mkdir, readdir, rename, copyFile, rm

ran at build time

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

fs.glob

ran at build time

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

Try the operation, handle ENOENT

ran at build time

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

Write, then rename

ran at build time

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

await using, for a file handle

ran at build time

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?

Two ways to read 20 MB

ran at build time

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.

A folder is not a file

dies with EISDIR

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.
  • mkdir and rm take { recursive: true }; readdir can recurse and report types; glob finds 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 using does 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

node:path

ran at build time

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:

The same join, two operating systems

ran at build time

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

Converting between the two

ran at build time

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

Characters versus bytes

ran at build time

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.

One set of bytes, four readings

ran at build time

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.

A character split across two chunks

ran at build time

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

Reading an image as bytes

ran at build time

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.win32 and path.posix apply either set of rules anywhere.
  • Convert URLs and paths with fileURLToPath and pathToFileURL, never by slicing strings.
  • A string's length is not its size in bytes. Buffer.byteLength is.
  • 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

process.argv

ran at build time

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.

util.parseArgs

ran at build time

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

An unknown flag

ran at build time

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

process.env, and every value is a string

ran at build time

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

node --env-file

ran at build time

.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

Reading what is piped in

ran at build time

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

The same program, fed three ways

ran at build time

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.

Exit codes, as the shell sees them

ran at build time

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.

process.exit() cuts things off; exitCode does not

ran at build time

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

Handling SIGTERM

ran at build time

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.parseArgs parses flags and rejects unknown ones.
  • Every environment variable is a string or undefined. Compare exactly; convert numbers.
  • node --env-file=.env loads 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.exitCode rather than calling process.exit().
  • Handle SIGTERM and SIGINT to 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

execFile

ran at build time

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

A program that fails, and one that does not exist

ran at build time

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.

Shell injection, with a harmless payload

ran at build time

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

spawn, reading output as it happens

ran at build time

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

A timeout on a child process

ran at build time

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

fork, and messages between processes

ran at build time

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.

  • execFile runs a program with an argument array and gives you its output at the end.
  • A non-zero exit rejects with err.code set to the exit code; a missing program gives ENOENT.
  • exec runs a string through a shell. Never put input into it; use execFile or spawn with an array.
  • spawn streams output as the child runs. Give long-running children a timeout or a signal.
  • fork starts a Node child with a message channel.

Newsletter

New writing, when it lands.

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