Node and the Server

Mental model: Node is the same language with a different set of host objects. No window or document, but a filesystem, a process, and the ability to listen on a port.

Level: beginner · about 18 minutes

Nothing you learned about the language changes on the server. Closures, promises, array methods, classes: identical, because it is the same V8 engine that runs Chrome. What changes is the set of objects the host hands you. The browser gave you a document and a user. Node gives you a filesystem, a process, and a socket you can listen on.

ConcernBrowserNode
global objectwindow / globalThisglobalThis, plus process and global
the documentdocument, DOM APIsnothing, there is no page
loading code<script type="module">, HTTPimport from disk or node_modules
storagelocalStorage, IndexedDBthe filesystem, a database
HTTPfetch (as a client)fetch as a client, node:http as a server
secretsimpossible, everything is publicprocess.env, never sent to the client
who is running itone visitor, in their browserevery visitor at once, on your machine
crashesone tab breaksthe server goes down for everyone

ESM in Node

// package.json
{
  "name": "my-server",
  "type": "module",          // without this, .js files are treated as CommonJS
  "scripts": { "start": "node src/server.js" }
}

// src/server.js
import { readFile } from 'node:fs/promises';   // built-in, prefixed with node:
import express from 'express';                  // from node_modules
import { formatPrice } from './lib/money.js';   // your own code: extension REQUIRED

const html = await readFile('./public/index.html', 'utf8'); // top-level await works

The modules you already know, with two Node-specific details: "type": "module" and the node: prefix.

The filesystem

import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
import { join } from 'node:path';

const raw = await readFile('data/products.json', 'utf8');  // without 'utf8' you get a Buffer
const products = JSON.parse(raw);

await mkdir('out', { recursive: true });                    // no error if it exists
await writeFile(join('out', 'report.json'), JSON.stringify(products, null, 2));

const files = await readdir('data');
console.log(files.filter((f) => f.endsWith('.json')));

Always the promise-based API. The callback and sync versions exist, and both have sharp edges.

Use node:path's join rather than string concatenation with slashes, because Windows and Linux disagree about separators and your CI runs on Linux. Never build a path by pasting user input into a string: a request for ../../etc/passwd is the oldest attack on the list. Resolve the path, then verify it still starts with the directory you meant to serve.

An HTTP server in fifteen lines

import { createServer } from 'node:http';

const server = createServer(async (req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    return res.end('ok');
  }
  if (req.url === '/api/time') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ now: Date.now() }));
  }
  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end('Not Found');
});

server.listen(3000, () => console.log('listening on http://localhost:3000'));

No framework. Save as server.js, run node server.js, open localhost:3000.

const routes = {
  'GET /health': () => ({ status: 200, body: 'ok' }),
  'GET /api/products': (req) => ({
    status: 200,
    body: { limit: Number(req.query.limit ?? 10) },
  }),
};

function handle(method, url) {
  const [path, search = ''] = url.split('?');
  const query = Object.fromEntries(new URLSearchParams(search));
  const route = routes[method + ' ' + path];
  if (!route) return { status: 404, body: 'Not Found' };
  return route({ method, path, query });
}

console.log(handle('GET', '/health'));                    // -> { status: 200, body: 'ok' }
console.log(handle('GET', '/api/products?limit=3'));      // -> { status: 200, body: { limit: 3 } }
console.log(handle('POST', '/health'));                   // -> { status: 404, body: 'Not Found' }

The routing logic on its own, as a pure function. This part you can test without starting a server.

const routes = { 'GET /users': 'list' };

function handle(method, url) {
  const path = url.split('?')[0];
  return routes[method + ' ' + path] ?? '404';
}

console.log(handle('GET', '/users?limit=2'), handle('POST', '/users'), handle('GET', '/users/'));

The query string is stripped, so the first call matches. The second fails because the method is part of the key: POST /users is a different route from GET /users, which is exactly the distinction that keeps a read endpoint from being a write endpoint. The third fails on the trailing slash, since string keys are exact. Real routers normalise trailing slashes for you, and that is one of the many small reasons to use one.

npm scripts are the project's user interface

{
  "scripts": {
    "dev": "node --watch src/server.js",   // restarts on save, no nodemon needed
    "start": "node src/server.js",         // what production runs
    "test": "node --test",                 // Node's built-in test runner
    "lint": "eslint .",
    "build": "vite build",
    "prepare": "husky install"             // "pre" and "post" prefixes run automatically
  }
}

// npm run dev          run any script
// npm test             a few names need no "run"
// npm run test -- --watch    everything after -- goes to the underlying command
// npx tsc --noEmit     run a binary without installing it globally

Read these first in any repository. They tell you how the project is meant to be operated.

Environment variables, and the line you must not cross

// read with a default, and fail loudly when something required is missing
const PORT = Number(process.env.PORT ?? 3000);
const DB_URL = process.env.DATABASE_URL;

if (!DB_URL) {
  console.error('DATABASE_URL is not set');
  process.exit(1);            // crash at startup, not on the first request
}

// Node 20.6+ can load a .env file with no library:
//   node --env-file=.env src/server.js
// .env goes in .gitignore. Commit .env.example with the KEYS and no values.

Configuration comes from the environment, not from source control.

Interactive visualiser: eventloop. Enable JavaScript to use it.

Try it yourself

Parse a .env file

const file = [
  '# database',
  'DATABASE_URL=postgres://localhost:5432/shop',
  '',
  'PORT=3000',
  'FEATURE_FLAG=true   # inline comment',
  'EMPTY=',
].join('\n');

function parseEnv(text) {
  const env = {};
  for (const raw of text.split('\n')) {
    const line = raw.trim();
    if (!line || line.startsWith('#')) continue;
    const eq = line.indexOf('=');
    if (eq === -1) continue;
    const key = line.slice(0, eq).trim();
    const value = line.slice(eq + 1).split('#')[0].trim();
    env[key] = value;
  }
  return env;
}

console.log(parseEnv(file));
// -> { DATABASE_URL: 'postgres://localhost:5432/shop', PORT: '3000', FEATURE_FLAG: 'true', EMPTY: '' }
console.log(typeof parseEnv(file).PORT); // -> string   always coerce it yourself

Add support for export KEY=value lines and for quoted values containing #. Then decide what your parser should do with a duplicate key: first wins, or last wins?

Reject a path traversal

function resolvePath(base, requested) {
  const parts = [];
  for (const segment of (base + '/' + requested).split('/')) {
    if (!segment || segment === '.') continue;
    if (segment === '..') parts.pop();
    else parts.push(segment);
  }
  return parts.join('/');
}

function serve(base, requested) {
  const full = resolvePath(base, requested);
  if (!full.startsWith(base + '/')) return { status: 403, body: 'Forbidden' };
  return { status: 200, body: 'serving ' + full };
}

console.log(serve('public', 'index.html'));        // -> 200
console.log(serve('public', 'css/site.css'));      // -> 200
console.log(serve('public', '../../etc/passwd'));  // -> 403
console.log(serve('public', 'a/../../.env'));      // -> 403

Try ../../etc/passwd, public/../secret.txt and an absolute path. Then add an allowlist of extensions, because a served .env is as bad as a served password file.

Exercises

A router you can test without a server

Write createRouter(routes) where routes maps "METHOD /path" to a handler. It returns handle(method, url). Strip any query string, parse it into a query object of strings, and call the handler with { method, path, query }. Return { status: 404, body: 'Not Found' } for an unknown route, and { status: 500, body: 'Internal Server Error' } if a handler throws.

Check yourself

Which of these does not exist in Node?
document — There is no page, so there is no document (or window, localStorage, or alert). fetch is available in modern Node as a client. globalThis is part of the language, so it exists in both. Libraries that assume document are the usual cause of "works in the browser, crashes on the server".
Your ESM Node file needs the directory it lives in. What do you reach for?
import.meta.dirname, or fileURLToPath(import.meta.url) on older versions — __dirname is CommonJS only and is undefined in an ES module. import.meta carries the module's own URL, which is the ESM replacement. process.cwd() is where the process was launched from, which is a different value and changes depending on how the script was started.
You put an API key in VITE_API_SECRET and use it in your front-end code. Who can read it?
Anyone, it is inlined into the bundle at build time — Build tools replace those references with the literal value while bundling, so the string sits in a JavaScript file anyone can download, search, and index. The prefix exists to mark a variable as deliberately public. A secret used from the browser has to move behind a server endpoint, and a leaked one must be rotated, not hidden.
One Node process handles every request. A handler runs a synchronous loop for one second. What happens?
Every other request waits, because the single thread is blocked — The event loop cannot pick up the next callback while your synchronous code holds the thread, so a one-second loop adds a second of latency for everyone currently waiting. This is why server code avoids readFileSync, giant synchronous JSON parses and unbounded loops in a request path, and moves real CPU work to a worker thread or a queue.

Common mistakes

  • Forgetting "type": "module" and then puzzling over "Cannot use import statement outside a module".
  • Omitting the file extension in a relative ESM import. Node will not guess .js for you.
  • Reaching for __dirname in an ES module, where it does not exist.
  • Reading a file without 'utf8' and getting a Buffer, then wondering why JSON.parse complains.
  • Building filesystem paths from user input, which is a path traversal waiting to happen.
  • Putting a real secret in a client-side environment variable. The prefix is a warning, not a protection.
  • Blocking the single thread with synchronous work in a request handler.

Takeaways

  • Node is the same language with different host objects: no document, but a filesystem, a process and a port.
  • Set "type": "module", prefix built-ins with node:, and include file extensions in relative imports.
  • Prefer node:fs/promises, pass 'utf8', and build paths with node:path rather than string concatenation.
  • An HTTP server is fifteen lines. Keep the routing pure so you can test it without a port.
  • npm scripts are the project's user interface. Read them first, and put every command a teammate needs there.
  • Secrets live in the server environment. Anything the browser can read is public, so rotate what leaks.