Working with Legacy Node.js Code
Your first job will probably not start with a fresh project. It will start with an app written five or eight years ago, by people who have left, using the habits of that time: require(), var, nested callbacks, Express 4 and packages that no longer exist. It still earns money, so it must keep working while you improve it.
Earlier lessons showed the modern way and mentioned old code in passing. This lesson puts the old patterns side by side with their modern replacements and, more importantly, teaches a safe way to migrate without breaking production.
What legacy Node.js code looks like
Here is a typical file from around 2017. Read it and count the things you would now write differently:
// server.js (Express 4, CommonJS, callbacks)
var express = require("express");
var bodyParser = require("body-parser");
var fs = require("fs");
var path = require("path");
var app = express();
app.use(bodyParser.json());
app.get("/reports/:id?", function (req, res) {
var id = req.param("id") || "latest";
fs.readFile(path.join(__dirname, "reports", id + ".json"), "utf8", function (err, text) {
if (err) return res.send(404);
res.json(JSON.parse(text), 200);
});
});
app.get("*", function (req, res) {
res.sendfile(__dirname + "/public/index.html");
});
module.exports = app;At least nine things: var, require/module.exports, body-parser (built into Express since 4.16 as express.json()), __dirname, a callback instead of fs/promises, an optional parameter with ?, req.param(), res.send(404) and res.json(obj, 200), the bare * route and res.sendfile() with a lowercase f. Several of these crash on Express 5. There is also a real bug: id comes from the URL and goes straight into a file path, which allows path traversal (see path & URL Modules). Legacy code often hides security problems like this one.
Rule one: tests before changes
Before touching legacy code, write characterisation tests: tests that record what the code does today, right or wrong. Use supertest and node:test (see Structuring a Real-World API) to call each important route and check the status and body. Then migrate in small steps, running the tests after each one. If a test changes, you know exactly which step changed behaviour. A big-bang rewrite of a working app is the most common way to turn a calm project into a crisis.
Callbacks to async/await
Old code passes error-first callbacks: functions called later with (err, result). Nested callbacks drift to the right ("callback hell") and every level must check err. You often cannot rewrite the module that provides them on day one, but you can wrap it. util.promisify() turns any error-first callback function into one that returns a promise:
// legacy-db.cjs: an old callback-style module you cannot change today
function findStudent(id, callback) {
setTimeout(() => {
if (id !== 7) return callback(new Error(`Student ${id} not found`));
callback(null, { id: 7, name: "Meera", classId: "10A" });
}, 10);
}
function findClass(classId, callback) {
setTimeout(() => callback(null, { id: classId, teacher: "Mr. Farhan" }), 10);
}
module.exports = { findStudent, findClass };import { promisify } from "node:util";
import legacyDb from "./legacy-db.cjs"; // ES modules can import CommonJS files
// The old way: nested callbacks ("callback hell")
legacyDb.findStudent(7, (err, student) => {
if (err) return console.error(err.message);
legacyDb.findClass(student.classId, (err, cls) => {
if (err) return console.error(err.message);
console.log(`callbacks: ${student.name} is taught by ${cls.teacher}`);
});
});
// The modern way: wrap once with promisify, then use async/await
const findStudent = promisify(legacyDb.findStudent);
const findClass = promisify(legacyDb.findClass);
try {
const student = await findStudent(7);
const cls = await findClass(student.classId);
console.log(`async/await: ${student.name} is taught by ${cls.teacher}`);
await findStudent(99);
} catch (err) {
console.log(`caught: ${err.message}`);
}callbacks: Meera is taught by Mr. Farhan async/await: Meera is taught by Mr. Farhan caught: Student 99 not found
The async version reads top to bottom, and one try...catch handles every error. For Node.js's own modules you do not need promisify: use node:fs/promises, node:timers/promises, node:dns/promises and node:stream/promises. If you see .then().then().catch() chains, they are promises already; converting them to await is a safe, mechanical change.
CommonJS to ES modules
The ES Modules & CommonJS lesson explained both systems. When migrating, these are the replacements you will need most:
| CommonJS | ES module |
|---|---|
const x = require("x") | import x from "x" |
module.exports = { a, b } | export { a, b } |
__dirname, __filename | import.meta.dirname, import.meta.filename |
require("./config.json") | import config from "./config.json" with { type: "json" } |
require() inside an if | await import("./x.js") |
A package that only works with require | createRequire(import.meta.url) |
// ESM replacements for CommonJS helpers
import { join } from "node:path";
import { createRequire } from "node:module";
import config from "./data/config.json" with { type: "json" };
console.log(import.meta.filename.endsWith("c.mjs")); // was __filename
console.log(join(import.meta.dirname, "uploads") === join(process.cwd(), "uploads")); // was __dirname
console.log(config.cities); // was require("./data/config.json")
const require = createRequire(import.meta.url); // for the rare package that needs require
console.log(require("./legacy-db.cjs").findStudent.length);true true [ 'Pune', 'Kochi' ] 2
import.meta.dirname and import.meta.filename need Node.js 20.11 or newer. The second true appears because the script was started from its own folder. Remember that ES module imports need the file extension (./db.js, not ./db), which is the change that breaks most first attempts.
Express 4 to Express 5
Express 5 removed several old methods and changed the route path syntax. Routes written the old way now throw at startup, which is good: you find them at once instead of in production.
import express from "express";
const app = express();
for (const path of ["/files/*", "/users/:id?", "/files/*splat", "/users{/:id}"]) {
try {
app.get(path, (req, res) => res.json(req.params));
console.log("OK ", path);
} catch (err) {
console.log("ERROR", path, "-", err.message);
}
}
const server = app.listen(0, async () => {
const base = `http://localhost:${server.address().port}`;
for (const url of ["/files/2026/report.pdf", "/users", "/users/42"]) {
console.log(url, "->", await (await fetch(base + url)).text());
}
server.close();
});ERROR /files/* - Missing parameter name at index 8: /files/*; visit https://git.new/pathToRegexpError for info
ERROR /users/:id? - Unexpected ? at index 10: /users/:id?; visit https://git.new/pathToRegexpError for info
OK /files/*splat
OK /users{/:id}
/files/2026/report.pdf -> {"splat":["2026","report.pdf"]}
/users -> {}
/users/42 -> {"id":"42"}A wildcard must now have a name (*splat) and its value is an array of path segments. Optional parts go in braces ({/:id}), and a missing optional parameter is simply left out of req.params. The other changes you will meet most often:
| Express 4 | Express 5 |
|---|---|
app.del(path, fn) | app.delete(path, fn) |
req.param("id") | req.params.id, req.query.id or req.body.id, chosen explicitly |
res.send(404) | res.sendStatus(404) |
res.json(obj, 201) | res.status(201).json(obj) |
res.redirect(url, 301) | res.redirect(301, url) |
res.sendfile() | res.sendFile() |
res.redirect("back") | res.redirect(req.get("Referrer") || "/") |
req.body is {} when nothing was parsed | req.body is undefined |
req.query.page = 1 works | req.query is read-only |
express.urlencoded() defaults to extended: true | Defaults to extended: false |
Async errors need try...catch or a helper package | Rejected promises go to the error handler automatically |
The Express team publishes codemods, scripts that rewrite the mechanical changes for you. Commit your work first so you can review the diff:
npx codemod@latest @expressjs/v5-migration-recipeCodemods do not handle everything: you still review route paths, req.params, req.query, req.body and express.urlencoded() by hand. Once async errors are handled by Express 5, you can also remove helper packages such as express-async-errors and try...catch blocks that only called next(err).
Other relics and their replacements
new Buffer(x): useBuffer.from(x)orBuffer.alloc(n).url.parse()andquerystring: use theURLclass andURLSearchParams.fs.exists(): deprecated; try the operation and handleENOENT, or usefs.promises.access().- The
requestHTTP package: deprecated in 2020; use the built-infetch. momentfor dates: in maintenance mode; its own docs recommend newer libraries such as Luxon or date-fns, and theTemporalAPI is coming to JavaScript.nodemonfor restarts:node --watchis built in (see REPL, Running Files & Watch Mode).dotenvfor.envfiles:node --env-file=.envorprocess.loadEnvFile()are built in.
Common mistakes
- Rewriting everything at once. Migrate one file or one route at a time, with tests running.
- Changing behaviour while modernising. Keep refactoring and bug fixes in separate commits so reviewers can follow them.
- Forgetting file extensions in ES module imports.
import "./db"fails; write./db.js. - Upgrading Express without searching for old route syntax. Search for
*,?andreq.param(before upgrading. - Leaving old security holes in place. While you are in the file, fix obvious issues such as unchecked paths and missing validation, in their own commit.
Key points
- Legacy code still earns money; write characterisation tests first, then migrate in small steps.
util.promisify()wraps error-first callback functions so new code can useasync/await.- Rename old CommonJS files to
.cjsand switch to ES modules gradually; useimport.meta.dirnameinstead of__dirname. - Express 5 needs named wildcards (
*splat), braces for optional parts, and the new forms of removed methods. - The official codemod (
npx codemod@latest @expressjs/v5-migration-recipe) handles the mechanical part of an Express 5 upgrade.
Exercise
Modernise the legacy server.js at the start of this lesson for Node.js 26 and Express 5: use ES modules, const, express.json(), fs/promises with async/await, valid Express 5 route paths, and modern response methods. Also fix the path traversal bug by allowing only report IDs made of letters, digits and hyphens.
Show solution
// server.js (Express 5, ES modules)
import express from "express";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export const app = express();
app.use(express.json());
app.get("/reports{/:id}", async (req, res) => {
const id = req.params.id ?? "latest";
if (!/^[a-z0-9-]+$/i.test(id)) return res.status(400).json({ error: "Invalid report id" });
try {
const text = await readFile(join(import.meta.dirname, "reports", `${id}.json`), "utf8");
res.status(200).json(JSON.parse(text));
} catch (err) {
if (err.code === "ENOENT") return res.sendStatus(404);
throw err; // Express 5 sends it to the error handler
}
});
app.get("/{*splat}", (req, res) => {
res.sendFile(join(import.meta.dirname, "public", "index.html"));
});/{*splat} matches every path including /, like the old *. The ID check means ../ can never reach the file path, and only a missing file becomes 404; other errors still reach the error handler.
Quick Quiz
1. What does util.promisify(fn) need from fn?
promisify turns the callback style into a promise-returning function.
2. Which ES module expression replaces __dirname?
process.cwd() is the folder Node.js was started from, which can differ.
3. In Express 5, how do you write a route that matches /users and /users/42?
Optional parts are written in braces; ? throws an error.
4. Why write characterisation tests before migrating legacy code?
Small steps plus tests make migration safe.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
