Node.js used to mean installing a package for everything: dotenv for config, nodemon for reloads, node-fetch for HTTP, Jest or Mocha for tests. Modern Node ships all of that built in. This crash course builds one real project with Node.js 24 LTS (24.22.1) and Express 5.3: a weather REST API that searches cities, stores favourites and serves live forecasts from the free Open-Meteo API. It uses only modern Node: ES modules, fs/promises, built-in fetch, --env-file, --watch and the node --test runner.
⚡ TL;DR – Set
"type": "module"in package.json and useimporteverywhere. – Built-infetch,--env-fileand--watchreplace node-fetch, dotenv and nodemon. – Express 5 forwards rejected promises from async handlers to your error middleware automatically. – Usestream.pipelinefor streams, so errors and cleanup are handled for you. – Move CPU-heavy work toworker_threadsso the event loop stays responsive.
Before you start
- Node.js 24 LTS:
node -v. - npm (bundled with Node) and
curlor a REST client. - No API key: Open-Meteo is free for non-commercial use.
Step 1: Project and ES modules
mkdir weather-api && cd weather-api
npm init -y
npm pkg set type=module
npm install express@5
// src/geo.js
export const roundTo = (n, d = 1) => Number(n.toFixed(d));
// src/server.js
import { roundTo } from './geo.js';
With "type": "module", .js files are ES modules. Relative imports need the file extension. Built-ins are imported with the node: prefix (node:fs/promises), which makes it obvious they aren’t npm packages.
Step 2: fs/promises and path
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
const DATA = path.join(import.meta.dirname, '..', 'data', 'favorites.json');
export async function loadFavorites() {
try { return JSON.parse(await readFile(DATA, 'utf8')); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
}
export async function saveFavorites(list) {
await mkdir(path.dirname(DATA), { recursive: true });
await writeFile(DATA, JSON.stringify(list, null, 2));
}
import.meta.dirname replaces __dirname, which doesn’t exist in ES modules. Resolve paths from the file’s own location, never from the current working directory, or the app breaks when started from another folder. Handle only ENOENT (file missing) and rethrow anything else.
Step 3: A raw HTTP server
import http from 'node:http';
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, url: req.url }));
}).listen(3000);
This is what Express builds on. Writing one by hand shows you what a framework adds: routing, body parsing and error handling.
Step 4: Built-in fetch
export async function geocode(name, { signal } = {}) {
const url = new URL('https://geocoding-api.open-meteo.com/v1/search');
url.search = new URLSearchParams({ name, count: 1 });
const r = await fetch(url, { signal: signal ?? AbortSignal.timeout(8000) });
if (!r.ok) throw new HttpError(502, `Geocoding failed: ${r.status}`);
const data = await r.json();
if (!data.results?.length) throw new HttpError(404, `No city called "${name}"`);
return data.results[0];
}
fetch has been global since Node 18. AbortSignal.timeout adds a timeout in one line. Like in the browser, fetch doesn’t reject on 4xx or 5xx responses, so check r.ok.
Step 5: Events
import { EventEmitter } from 'node:events';
export const bus = new EventEmitter();
bus.on('favorite:added', city => console.log(`[audit] added ${city}`));
// later
bus.emit('favorite:added', 'London');
Events decouple the code that does something from the code that reacts to it, such as logging, caching or notifications. Listeners run synchronously in registration order, so keep them fast.
Step 6: An Express 5 REST API
import express from 'express';
const app = express();
app.use(express.json());
app.get('/api/forecast/:city', async (req, res) => {
const place = await geocode(req.params.city);
res.json(await forecast(place));
});
app.get('/api/favorites', async (_req, res) => res.json(await loadFavorites()));
app.post('/api/favorites', async (req, res) => {
const { city } = req.body ?? {};
if (typeof city !== 'string' || !city.trim()) throw new HttpError(400, 'city is required');
const list = await loadFavorites();
if (!list.includes(city)) { list.push(city); await saveFavorites(list); bus.emit('favorite:added', city); }
res.status(201).json(list);
});
app.listen(process.env.PORT ?? 3000);
The big Express 5 change: if an async handler throws or rejects, Express forwards the error to your error middleware. In Express 4 the same mistake caused an unhandled rejection and a request that hung forever.
Step 7: Middleware
app.use((req, res, next) => {
const start = performance.now();
res.on('finish', () =>
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${(performance.now() - start).toFixed(0)}ms`));
next();
});
Middleware runs in the order you register it. Logging goes first; the error handler goes last.
Step 8: Centralised error handling
export class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use((err, req, res, _next) => {
const status = err.status ?? 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: status >= 500 ? 'Internal error' : err.message });
});
The four-argument signature is how Express recognises an error handler. Don’t leak stack traces or internal messages for 500s.
Step 9: Environment variables with –env-file
# .env (add to .gitignore)
PORT=3000
FORECAST_DAYS=7
node --env-file=.env src/server.js
Built into Node, so you don’t need dotenv. Values arrive as strings: convert them with Number(process.env.FORECAST_DAYS).
Step 10: Auto-restart with –watch
"scripts": {
"dev": "node --watch --env-file=.env src/server.js",
"start": "node --env-file=.env src/server.js",
"test": "node --test"
}
--watch restarts the server whenever an imported file changes, so you don’t need nodemon.
Step 11: Streams
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
app.get('/api/export.csv', async (req, res) => {
const list = await loadFavorites();
res.type('text/csv');
await pipeline(Readable.from((function* () {
yield 'city\n';
for (const c of list) yield `${c}\n`;
})()), res);
});
Streams send data piece by piece instead of holding it all in memory. pipeline wires the streams together, propagates errors and closes everything if the client disconnects. A bare .pipe() does none of that.
Step 12: Async patterns
app.get('/api/compare', async (req, res) => {
const cities = String(req.query.cities ?? 'Chennai,London,Tokyo,Paris,Toronto').split(',').slice(0, 5);
const results = await Promise.allSettled(cities.map(async c => forecast(await geocode(c))));
res.json(cities.map((c, i) => results[i].status === 'fulfilled'
? { city: c, forecast: results[i].value }
: { city: c, error: results[i].reason.message }));
});
The lookups run in parallel, and allSettled returns partial results instead of failing the whole request. Avoid await inside a for loop when the calls don’t depend on each other. That makes them run one after another.
Step 13: Worker threads
// stats-worker.js
import { parentPort, workerData } from 'node:worker_threads';
parentPort.postMessage(heavyStats(workerData));
// server.js
import { Worker } from 'node:worker_threads';
const runStats = data => new Promise((resolve, reject) => {
const w = new Worker(new URL('./stats-worker.js', import.meta.url), { workerData: data });
w.once('message', resolve).once('error', reject);
});
Node runs your JavaScript on one thread. A CPU-heavy loop blocks every request until it finishes. Workers run on separate threads and communicate by message. For I/O you don’t need them; async already handles that.
Step 14: Testing with node –test
// test/api.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { roundTo } from '../src/geo.js';
test('roundTo rounds to one decimal by default', () => {
assert.equal(roundTo(21.456), 21.5);
});
npm test
The built-in runner supports describe, test, mocking (mock.method) and --watch. For API tests, export app without calling listen, start it on port 0 in a before hook and fetch against it.
Troubleshooting
ERR_MODULE_NOT_FOUND: a relative import is missing its.jsextension.__dirname is not defined: useimport.meta.dirname.req.bodyis undefined: addexpress.json()and sendContent-Type: application/json.- The server becomes unresponsive: synchronous CPU work on the main thread. Move it to a worker.
EADDRINUSE: another process holds the port. ChangePORTor stop the old process.
Best practices
- Validate input at the edge and throw
HttpErrors with clear statuses. - Put timeouts on every outbound request.
- Keep secrets in
.env, out of git. - Use
pipelinefor streams andallSettledfor independent parallel work.
Edge cases: graceful shutdown and caching
Graceful shutdown. When a container or process manager stops your app, it sends SIGTERM. Stop accepting new connections and let in-flight requests finish:
const server = app.listen(process.env.PORT ?? 3000);
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref(); // force-quit if stuck
});
Cache upstream responses. Forecasts don’t change every second. A small TTL cache cuts latency and keeps you well within the API’s fair-use limits:
const cache = new Map();
async function cached(key, ttlMs, load) {
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value;
const value = await load();
cache.set(key, { value, expires: Date.now() + ttlMs });
return value;
}
// usage: await cached(`fc:${city}`, 10 * 60_000, () => forecast(place))
Concurrent writes to the JSON file. Two POSTs at once can read the same list and overwrite each other. Serialise writes through a promise chain, or move to SQLite (node:sqlite) when the data matters.
Unhandled rejections. Since Node 15 they crash the process. That’s a feature: add process.on('unhandledRejection', ...) only to log, and let your process manager restart the app.
FAQ
Is Express still worth learning?
Yes. Express 5 is stable, has a huge ecosystem, and is the reference point for Fastify, Hono and others.
Do I still need TypeScript?
It isn’t required, but it’s recommended for larger APIs.
Official documentation
Key takeaways
- Modern Node needs far fewer dependencies.
- Express 5 handles async errors properly. Centralise them in one handler.
- Streams and
pipelinekeep memory flat; workers keep the event loop free. node --testis enough to start testing today.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.