IntermediateNode.js and cross-runtime JavaScript~45 min

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:

JavaScriptStreaming a large file instead of reading it whole
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

Shared language versus runtime-specific host APIs
LayerExampleShared across browser/Node/Deno/Bun?
ECMAScriptlet, Array.prototype.map, Promise, classesYes — identical everywhere
Web-standard APIsfetch, URL, TextEncoderIncreasingly shared — most runtimes now implement these too
Node-specific APIsfs, process, node:httpNo — browser-only or Node-only, by design
Browser-specific APIsdocument, window, localStorageNo — 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

A file is named config.mjs. Its package.json has no "type" field at all. What module system does Node use for it?
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).

A Node script reads an entire 4GB file into memory with fs/promises' readFile before processing it, and crashes. What's the likely fix?
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.

  1. Hint 1

    fs/promises exports the same function names, but each one returns a promise instead of taking a callback.

  2. 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.

  1. Hint 1

    createReadStream emits "data" events with chunks — count newline characters (\n) across all chunks as they arrive.

  2. 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 .js files, while .mjs/.cjs always force one or the other.
  • fs/promises is 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.