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.
| Concern | Browser | Node |
|---|---|---|
| global object | window / globalThis | globalThis, plus process and global |
| the document | document, DOM APIs | nothing, there is no page |
| loading code | <script type="module">, HTTP | import from disk or node_modules |
| storage | localStorage, IndexedDB | the filesystem, a database |
| HTTP | fetch (as a client) | fetch as a client, node:http as a server |
| secrets | impossible, everything is public | process.env, never sent to the client |
| who is running it | one visitor, in their browser | every visitor at once, on your machine |
| crashes | one tab breaks | the 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 worksThe 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 globallyRead 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 nodocument(orwindow,localStorage, oralert).fetchis available in modern Node as a client.globalThisis part of the language, so it exists in both. Libraries that assumedocumentare 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, orfileURLToPath(import.meta.url)on older versions —__dirnameis CommonJS only and isundefinedin an ES module.import.metacarries 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_SECRETand 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
.jsfor you. - Reaching for
__dirnamein an ES module, where it does not exist. - Reading a file without
'utf8'and getting aBuffer, then wondering whyJSON.parsecomplains. - 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 withnode:, and include file extensions in relative imports. - Prefer
node:fs/promises, pass'utf8', and build paths withnode:pathrather 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.