The full rewrite is always announced for a year from now. A year later, the legacy system is still in production, the new version covers half the scope, and the team is maintaining two systems instead of one. Migrating a Node.js backend without stopping production is done differently: in shippable slices, in a chosen order, while the platform keeps delivering every sprint. Here is the method we apply.
Map everything before touching anything
You cannot migrate what you cannot see. Before the first line changes, we build a map: the services, their runtime versions, their critical dependencies, the consumers of every endpoint, the scheduled jobs, the message queues. Alongside the map, a risk register: what has no tests, what has no usable logs, what has no identified owner.
What this mapping reveals is remarkably consistent from one platform to the next: endpoints nobody calls anymore, endpoints called by a partner nobody has a contact for, a forgotten cron writing to a table that two services read, environment variables whose production value matches no documentation. Far better to discover all of this on paper than in the middle of a cutover.
The map does not need to be perfect. It needs to be good enough to order the slices: start with the low-risk services with decent test coverage, to shake down the process, and keep the ones everyone avoids for last. The reverse order, tackling the scariest service first "to get the worst out of the way", is a classic mistake: you end up working out the kinks of the method on the service that forgives the least.
Node 14 to 18 to 22: a slice is a deployed service
The rule that holds the whole migration together: a slice ends in production, not in a branch. No "migration" branch that lives for weeks and drifts away from trunk. A service is upgraded, tested, deployed through the usual pipeline, observed, and then you move on to the next one.
On a B2B cloud marketplace, around twenty services moved from Node 14 to 18 and then 22 this way, while the platform kept delivering every sprint. Going through 18 before 22 is not excessive caution: each jump isolates one family of breakages, and a service that breaks on 18 is diagnosed far faster than a service that breaks after a jump of four major versions.
What actually breaks in these jumps:
- OpenSSL 3 starting with Node 17: removed algorithms, stricter TLS options, old certificates and old signing libraries that no longer get through.
- Native modules: anything that compiles C++ at install time has to be rebuilt, and some abandoned packages simply have no compatible version. This is where you pay for the dependencies chosen ten years earlier.
- DNS resolution: since Node 17, results are no longer reordered in favor of IPv4, which surfaces timeouts on infrastructures where IPv6 is half configured.
- The removal of long-deprecated APIs that the code was quietly using because nobody read the warnings in CI.
During the transition, CI tests against both Node versions. That is what makes it possible to move service by service without imposing a global order: an urgent fix can ship on a service still running Node 14 without waiting for the migration to catch up with it.
AWS SDK v2 to v3: the migration everyone underestimates
On paper, it is a change of imports. In practice, SDK v3 changes the model: modular clients, command pattern, different pagination, differently typed errors, the disappearance of .promise(). Codemods automate the mechanical part; the real work is elsewhere, in the places where the code depended on v2's implicit behavior: default retries, timeouts, DynamoDB marshalling.
// SDK v2: monolithic client, the whole SDK loads at startup
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const obj = await s3.getObject({ Bucket, Key }).promise();
// SDK v3: modular client, command pattern
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({});
const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
Having v2 and v3 coexist in the same service during the transition is acceptable, provided it is a transitional state, not a permanent arrangement: the slice is cut per service, not per line. The gain is not cosmetic. Modular clients lend themselves to tree-shaking, and on Lambda functions, the smaller bundle and loading clients on demand are a large part of what took cold starts from around 8 seconds to around 1.5.
The strangler pattern on legacy endpoints
For services that an upgrade alone cannot save, we do not replace the whole service at once: we apply the strangler pattern. A single entry point in front of the legacy system, API Gateway or a reverse proxy, then an endpoint-by-endpoint migration: the new implementation takes over one route, responses are compared on real traffic, the cutover happens, the old route is removed.
Two conditions keep the pattern honest:
- Parity must be observable. Structured logs and a correlation ID on both sides, otherwise "the responses are identical" is an opinion, not a fact.
- Each cutover must be independently reversible. Roll back one route, not the whole migration.
A slice that cannot be rolled back is not a slice, it is a bet.
And the part teams forget: deleting. A strangler that never finishes strangling leaves two systems in production, the worst of both worlds. As long as the old route still exists, the slice is not done.
A backend nobody wants to touch anymore? Describe it: a one-page assessment within 48 hours.
Get my assessment →Harmonizing errors while you are at it
Backends that grew fast answer 500 for everything: invalid input, broken dependency, bug, it all looks the same. In normal times, that is a support problem. During a migration, it is a methodology problem: you cannot compare the behavior of the old and the new implementation if every failure is opaque.
So we define an error contract early in the project: explicit codes, a documented response body, a correlation ID. The contract is applied to the legacy endpoints first, thankless but bounded work, then every migrated slice inherits it. By the end, the platform had gone from opaque 500s to explicit, documented codes. That was not the stated goal of the migration; it was its scaffolding.
Why the full rewrite announced for a year from now fails
The big-bang rewrite rarely fails through incompetence. It fails by construction:
- The specification is the code. Nobody knows everything a backend with ten years of production behind it does. The rewrite rediscovers the business rules one by one, often after the cutover, in customers' hands.
- The legacy is frozen, the business is not. Requests keep coming in. Either they go into the legacy system and the target chases a moving specification, or they wait and the rewrite becomes responsible for everything that does not ship.
- The value arrives at the end, which often means never. A slice-by-slice migration puts something in production from the very first slice. A rewrite delivers nothing until it has delivered everything, and cutover day concentrates all the accumulated risk into a single event.
We are not saying a rewrite never succeeds: on a small, closed scope the team knows well, it can be the right choice. We have never seen "we will rewrite everything in a year" hold on a platform that has to keep delivering, and we do not propose it.
What stays hard, even with the method
Slice-by-slice migration does not erase the difficulties, it makes them manageable. What remains hard:
- The "while we're at it" temptation. A slice that was supposed to upgrade a runtime starts refactoring the architecture, grows, and stops being shippable. Holding a slice's scope is a discipline, not a given.
- Hidden consumers. The mapping finds many of them, never all. Some surface after a cutover; that is exactly why per-slice reversibility is not negotiable.
- Migration fatigue. After several months, the team wants to move on, and the last services, often the worst ones, remain. The initial register also serves this purpose: knowing exactly what is left, and not declaring victory three quarters of the way there.
And two things we no longer do. Migrating a service's runtime, SDK and architecture in the same slice: when an incident occurs, it is impossible to attribute the cause. And starting a migration without first bringing monitoring up to standard: you do not steer a cutover blind, and the legacy you take over is rarely well instrumented.
This is exactly the scope our backend takeover and modernization offer covers: taking over a codebase we did not write, mapping it, then modernizing it in slices while delivery continues.