Files, Paths & Processes

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

The last lesson separated JavaScript the language from Node the runtime. This lesson uses the first Node powers you reach for in real scripts: reading and writing files, building paths safely, reading process input, and ending with a status code another tool can understand.

These are not glamorous APIs, but they are the floor under server-side JavaScript. Test runners, build scripts, code generators, deploy commands, and API servers all stand on them.

Files with node:fs/promises

Node's file-system module is called node:fs. The version you should usually start with is node:fs/promises, because its functions return promises and work naturally with async/await.

This example writes a small text file, lists the folder, reads the file back, and cleans up. It uses a temporary folder so you can run it without leaving files in your project.

import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
 
const workspace = await mkdtemp(join(tmpdir(), "node-files-"));
 
try {
  const notesDir = join(workspace, "notes");
  const notePath = join(notesDir, "today.txt");
 
  await mkdir(notesDir);
  await writeFile(notePath, "Ship small pieces.\n", "utf8");
 
  const names = await readdir(notesDir);
  const note = await readFile(notePath, "utf8");
 
  console.log(names[0]);
  console.log(note.trim());
} finally {
  await rm(workspace, { recursive: true, force: true });
}

today.txt, then Ship small pieces.

Two details matter right away. First, text files need an encoding like "utf8"; without it, readFile gives you bytes instead of a string. Second, file operations can fail: the file might be missing, permissions might block access, or a folder might not exist. With fs/promises, those failures reject, so ordinary try/catch is the shape.

import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
 
const workspace = await mkdtemp(join(tmpdir(), "node-missing-"));
 
try {
  await readFile(join(workspace, "not-here.txt"), "utf8");
} catch (error) {
  console.log(error.code);
} finally {
  await rm(workspace, { recursive: true, force: true });
}

ENOENT — Node's short code for "no such file or directory."

Older Node examples often use callbacks: fs.readFile("x.txt", (error, data) => { ... }). You will still meet that style in older code. For new code, promise APIs keep file work in the same await rhythm you already use for fetch, tests, and database calls.

Paths are data, not string puzzles

A path is not just "a string with slashes." It has parts: directory, base name, extension, parent folders, and sometimes a drive or root. Instead of slicing strings by hand, use node:path helpers so the runtime handles path rules for the current operating system.

import { basename, dirname, extname, join } from "node:path";
 
const reportPath = join("reports", "2026", "summary.json");
 
console.log(basename(reportPath));
console.log(extname(reportPath));
console.log(dirname(reportPath).endsWith("2026"));

summary.json, then .json, then true.

join(...) assembles path segments. basename(...) gets the last piece. dirname(...) gets the folder part. extname(...) gets the final extension. Those helpers are clearer than code like filePath.split("/").pop() because the helper says what you mean and does not bake a separator choice into your logic.

ES modules also give you a portable way to point at files beside the current module: file URLs. import.meta.url is the URL of the current module, and new URL("./config/app.json", import.meta.url) means "the file config/app.json next to this module."

import { basename } from "node:path";
import { fileURLToPath } from "node:url";
 
const configUrl = new URL("./config/app.json", import.meta.url);
const configPath = fileURLToPath(configUrl);
 
console.log(configUrl.protocol);
console.log(configUrl.pathname.endsWith("/config/app.json"));
console.log(basename(configPath));

file:, then true, then app.json.

Many file APIs, including readFile, accept a URL object directly. Convert with fileURLToPath(...) when you need to pass the value to a path helper or a library that expects a path string.

Processes have input and output

When you run node script.mjs, Node starts an operating-system process. That process receives command-line arguments, environment variables, standard output, standard error, and eventually an exit code.

process.argv is the argument list. The first two entries are Node's executable path and the script path, so your own arguments start at slice(2).

const [name = "friend", mode = "normal"] = process.argv.slice(2);
 
const message = mode === "--loud" ? `HELLO, ${name.toUpperCase()}!` : `Hello, ${name}.`;
 
console.log(message);

Hello, friend. — with no command-line arguments, the defaults run.

Run the same file with arguments:

node args-demo.mjs Ada --loud
# HELLO, ADA!

Environment variables live on process.env. They are always strings or missing values, so validate them the same way you validate network or form input. This example uses an environment variable for a mode and sets a non-zero exit code if the mode is invalid.

const mode = process.env.COURSE_APP_MODE ?? "development";
const validModes = new Set(["development", "test", "production"]);
 
if (!validModes.has(mode)) {
  console.error(`Invalid COURSE_APP_MODE: ${mode}`);
  process.exitCode = 1;
} else {
  console.log(`Mode: ${mode}`);
}

Mode: development — missing environment variables need ordinary defaults.

In a bash-style shell, the environment variable goes before the command:

COURSE_APP_MODE=production node env-mode.mjs
# Mode: production
 
COURSE_APP_MODE=staging node env-mode.mjs
# Invalid COURSE_APP_MODE: staging
echo $?
# 1

An exit code is the process's final verdict. 0 means success. Non-zero means failure. That small number is how scripts talk to other tools: test runners fail CI with a non-zero code, build scripts stop deploys with a non-zero code, and shell commands can branch based on success or failure. Prefer process.exitCode = 1 when you can, because it lets pending logs and cleanup finish before Node exits.

Environment variables are also where deployments provide secrets and per-environment config. This lesson only teaches how to read them; lesson 22.5 gives secrets and deployment configuration the full treatment.

Streams, honestly

A stream is data delivered in pieces over time instead of loaded all at once. Streams solve real problems: reading a huge file without keeping the whole file in memory, piping an upload to storage, compressing a response while it is being sent, or processing logs as they arrive. You will see streams in process.stdin, process.stdout, file read/write streams, HTTP request and response bodies, compression, uploads, and many cloud SDKs. They also bring work-specific details such as backpressure, transforms, pipeline error handling, and cleanup. For small config files and normal JSON files, fs/promises is the right starting point. Reach for streams when the data is large, continuous, or naturally chunked.

Next, these same process and runtime ideas become a server. You will use node:http to receive requests, send responses, and then compare that low-level shape with Express and Fastify.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion