Node.js and Cross-Runtime JavaScript
Node's core APIs — the file system, streams, processes — the two common async API styles, a minimal HTTP server, and what genuinely differs between JavaScript runtimes.
By the end of this lesson, you can
- Read and write files with Node's fs/promises API, and explain when a stream is preferable to reading a whole file into memory
- Distinguish an error-first callback API from a promise-based one, and convert between them
- Build a minimal HTTP server and explain why input from it must be treated as untrusted
- Explain what genuinely differs between a browser, Node, and other runtimes, versus what's shared ECMAScript
Why it matters
Every lesson so far has run identically in a browser or Node, or has been browser-specific. Node has its own substantial set of host APIs — recall the language/host-API distinction from Orientation — for the file system, processes, and networking, plus its own module history predating ES modules.
Module resolution: CommonJS and ESM
Node supports two module systems. CommonJS (require/module.exports) is the older, still
extremely common default; ES modules (import/export, from Modules, Tooling, and First
Tests) are the modern standard:
// CommonJS
const fs = require('fs');
module.exports = { readConfig };
// ES modules
import fs from 'node:fs';
export { readConfig };
Node decides which system a .js file uses based on the nearest package.json’s "type" field
("module" for ESM, or its absence/"commonjs" for CommonJS) — recall package.json and
package-lock.json — or the file extension (.mjs always ESM,
.cjs always CommonJS, regardless of "type").
The file system and streams
import { readFile, writeFile } from 'node:fs/promises';
const data = await readFile('config.json', 'utf-8');
const config = JSON.parse(data);
await writeFile('config.json', JSON.stringify({ ...config, updated: true }, null, 2));
fs/promises reads an entire file into memory — fine for small files, wasteful (or impossible) for
a very large one. A stream processes data in chunks as it arrives, without ever holding the
whole thing in memory at once:
import { createReadStream, createWriteStream } from 'node:fs';
const input = createReadStream('large-log.txt');
const output = createWriteStream('filtered-log.txt');
input.on('data', (chunk) => {
const filtered = chunk
.toString()
.split('\n')
.filter((line) => line.includes('ERROR'));
output.write(filtered.join('\n'));
});A buffer (Buffer, a Node-specific type) holds raw binary data — what a stream’s data event
actually delivers by default, before any text decoding.
Two async API styles
Older Node APIs use error-first callbacks: the callback’s first parameter is either an error or
null, its second the result. Modern Node APIs (like fs/promises above) are promise-based instead:
import { readFile } from 'node:fs'; // callback-based
readFile('config.json', 'utf-8', (err, data) => {
if (err) {
console.error('Read failed:', err.message);
return;
}
console.log(JSON.parse(data));
});
A minimal HTTP server
import { createServer } from 'node:http';
const server = createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
return;
}
res.writeHead(404);
res.end('Not found');
});
server.listen(3000);
What actually differs between runtimes
| Layer | Example | Shared across browser/Node/Deno/Bun? |
|---|---|---|
| ECMAScript | let, Array.prototype.map, Promise, classes | Yes — identical everywhere |
| Web-standard APIs | fetch, URL, TextEncoder | Increasingly shared — most runtimes now implement these too |
| Node-specific APIs | fs, process, node:http | No — browser-only or Node-only, by design |
| Browser-specific APIs | document, window, localStorage | No — meaningless outside a browser |
Deno and Bun are separate JavaScript runtimes with their own trade-offs (built-in TypeScript support, different package/permission models) — worth being aware of, but the fundamentals in this curriculum (the language itself, and the widely-shared web-standard APIs) transfer regardless of which specific runtime a project happens to use.
Check your understanding
Or reveal the answer without checking
Answer:ES modules — the .mjs extension always means ESM, regardless of package.json
The .mjs extension always forces ES module treatment, and .cjs always forces CommonJS — both override whatever package.json's "type" field says (or its absence, which defaults plain .js files to CommonJS).
Or reveal the answer without checking
Answer:Use a stream (createReadStream) to process the file in chunks instead of loading it all into memory at once
readFile loads the entire file into memory at once — fine for small files, but impractical or impossible for very large ones. A stream processes data in chunks, never holding the whole file in memory simultaneously.
Practice: warm-up
Convert this error-first callback call into an async/await call using fs/promises:
import { readFile } from 'node:fs';
readFile('data.json', 'utf-8', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(JSON.parse(data));
});
Stuck? Reveal one hint at a time.
Hint 1
fs/promises exports the same function names, but each one returns a promise instead of taking a callback.
Hint 2
A rejected promise from readFile throws inside an async function — wrap the call in try/catch to replace the err check.
Reveal the converted version
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
import { readFile } from 'node:fs/promises';
try {
const data = await readFile('data.json', 'utf-8');
console.log(JSON.parse(data));
} catch (err) {
console.error(err);
}Practice: apply it
Write a small Node script countLines(filePath) that counts the number of lines in a file using a
stream — without reading the whole file into memory at once — and returns the count via a promise.
Stuck? Reveal one hint at a time.
Hint 1
createReadStream emits "data" events with chunks — count newline characters (\n) across all chunks as they arrive.
Hint 2
Wrap the stream in a new Promise, resolving on the stream's "end" event and rejecting on "error".
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
import { createReadStream } from 'node:fs';
function countLines(filePath) {
return new Promise((resolve, reject) => {
let count = 0;
const stream = createReadStream(filePath, { encoding: 'utf-8' });
stream.on('data', (chunk) => {
count += chunk.split('\n').length - 1;
});
stream.on('end', () => resolve(count));
stream.on('error', reject);
});
}Summary
- Node supports both CommonJS (
require/module.exports) and ES modules (import/export);package.json’s"type"field decides for plain.jsfiles, while.mjs/.cjsalways force one or the other. fs/promisesis simplest for whole-file reads; a stream processes data in chunks, avoiding loading an entire large file into memory at once.- Error-first callbacks (
(err, result) => {}) predate promises in Node and remain common in older/third-party APIs — always check the error argument first. - Any data read from an HTTP request is untrusted input and must be validated at the boundary, exactly like any other external input.
- ECMAScript and increasingly the web-standard APIs (
fetch,URL) are shared across runtimes;fs,process, and similar remain Node-specific — the fundamentals transfer regardless of which runtime (Node, Deno, Bun) a project uses.