Incremental migration beats rewrites every single time
I've watched teams commit to "just rewrite this in the new stack" at least a dozen times. Every single time, the project took twice as long as planned, shipped nothing to users for months, and created a maintenance nightmare with two codebases running in parallel. The alternative—incremental migration beats rewrites—is not just faster. It's the only way I've seen teams actually finish migrations while shipping features and fixing bugs in production.
The rewrite trap
Rewrites feel good in planning meetings. The architecture is clean, the codebase is fresh, and there's a clear "launch day" on the calendar. What actually happens is predictable: scope creeps, hidden complexity emerges, and by month three you're maintaining two separate systems because users still depend on the old one.
I worked on a trading platform where we tried to rewrite the order entry screen in Next.js. We estimated three months. At month four, we'd rebuilt 40% of it and discovered five edge cases the old code handled that nobody documented. Meanwhile, the legacy system was still getting bug fixes and feature requests. We ended up maintaining both, with neither team talking to the other.
Incremental migration avoids this trap. Instead of a big bang, you migrate piece by piece, validate each piece works, and ship value the entire time.
Ship value while migrating
The core principle is simple: every migration task should either ship a feature, fix a bug, or improve performance for real users. This sounds obvious, but teams often treat migrations as separate work that happens "after we ship."
On our e-commerce platform, we needed to move from class components to functional components with hooks. Instead of a blanket refactor, we tied each migration to user value. When we refactored the cart screen, we also optimized it to reduce re-renders. When we migrated the product detail page, we added image lazy loading. By the time we finished, we'd improved Core Web Vitals by 30% while cleaning up the codebase.
The payoff is huge: stakeholders see results, engineers stay motivated, and if something goes wrong with one component, you haven't blocked everything else. Users also get continuous improvements instead than waiting months for a "big migration launch."
The key is being intentional about bundling work:
- Migrating a form? Add client-side validation improvements at the same time.
- Moving a list component? Implement pagination or filtering while you're at it.
- Upgrading dependencies? Profile performance and optimize slow paths.
Strangler pattern for frontends too
The strangler pattern was invented for backend migrations—gradually replace the old system by wrapping it with the new one. Most people think it only works for APIs, but it's equally powerful for frontend migrations.
The idea: your new code and old code coexist. Requests go to the new code first. If it can't handle them, they fall back to the old code. Gradually, you strangle out the old system as the new one takes over more responsibility.
On a ride-hailing app, we migrated from a custom router to Next.js. Instead of replacing everything at once, we:
- Deployed Next.js alongside the old app
- Routed only the driver profile screen to Next.js
- Verified it worked for 10% of users
- Gradually increased to 100%, then moved on to the next screen
- After three months, the old app was running nothing but legacy screens
This looks like extra complexity, but the safety is worth it. Here's a minimal router wrapper I've used:
// router.ts - wraps both old and new implementations
const MIGRATED_ROUTES = {
'/driver/profile': 'new',
'/driver/earnings': 'old',
};
export function routeRequest(path: string) {
const status = MIGRATED_ROUTES[path];
if (status === 'new') {
return routeToNextJs(path);
}
return routeToLegacyApp(path);
}
// Gradually update this map as you migrate
// MIGRATED_ROUTES['/driver/earnings'] = 'new';
You can make this more sophisticated with feature flags, but the principle stays the same: new code and old code run in parallel, and you control which path each request takes.
Measure progress by screens migrated
Forget measuring migration progress by "percentage of code." That's useless. What matters is how many user-facing screens, flows, and features work in the new stack.
On our CRM platform, we tracked migration like this:
| Screen | Status | Users affected | Date shipped |
|---|---|---|---|
| Contact list | Migrated | 100% | Week 2 |
| Contact detail | Migrated | 100% | Week 4 |
| Activity feed | In progress | — | — |
| Email integration | Old stack | 80% | — |
This tracking is concrete. Everyone knows exactly which users are on the new code. You can identify blockers quickly—if a screen is stuck "in progress" for two weeks, you talk about why. And you can celebrate wins: shipping the contact list to 100% of users is a real milestone.
The metric that matters most is "percentage of daily active users on new stack." That's your real progress bar. If 60% of your daily active users are using migrated screens, you're well on your way. At 90%, the old code is running on fumes.
Delete old code as you go
This is the part most teams skip, and it's a mistake. Leaving old code around creates technical debt that compounds. You're maintaining two APIs, two component libraries, two ways of handling errors.
As soon as a screen is fully migrated and running stable in production for at least two weeks, delete the old code. Not "refactor it later." Delete it now.
On the travel platform, we kept the old booking screen code for "just in case." Six months later, we discovered users were hitting bugs in code we thought nobody was using. The old code had five different error handlers, three of them contradicting each other. We spent a week debugging it when we should have just deleted it the day we shipped the new version.
Deleting old code also forces you to think clearly about dependencies. When you go to delete the old cart component and realize it's imported in 12 places, that's not a problem—that's information. It tells you there's either shared logic you need to extract or interfaces you haven't fully migrated.
Set a rule: no old code runs in production for more than one month without a migration plan. If it's still there after that, it's a priority.
Common mistakes
Running two codebases in parallel indefinitely. I've seen teams maintain old and new code for over a year with no migration velocity. Set hard deadlines. "We ship the old code by Q4" is a commitment, not a suggestion.
Migrating components that don't matter. Don't optimize for code elegance. Prioritize by user impact. A component that 1,000 users touch every day matters more than a beautiful utility that powers one admin page.
Assuming new code will be faster without measuring. Performance often gets worse during migrations because you're learning as you go. Measure actual Core Web Vitals and real user monitoring. If the new code is slower, fix it before migrating more screens.
Not documenting the old behavior. Before you delete old code, write down what it does. Edge cases, error handling, undocumented features—if it's in production, someone probably depends on it. Document it so the new code doesn't miss anything.
Key takeaways
- Rewrites take twice as long as planned. Incremental migration avoids the trap by shipping value continuously.
- Bundle migration work with user-facing improvements—better performance, new features, bug fixes.
- Use the strangler pattern: old and new code coexist, with gradual handoff of traffic.
- Track progress by screens migrated and percentage of daily active users on new stack, not lines of code.
- Delete old code as soon as it's replaced. Leaving it around creates maintenance debt that compounds.
- Set deadlines for sunsetting old code. "Eventually" never happens.






Comments
Loading comments…