Expert Opinion

Migrating the heart of a live product in nine batches

Migrating the heart of a live product in nine batches

The surface I was most afraid to touch was the one people used every day. It was the builder at the centre of PerkZilla, a US referral marketing product I ran for ten years, the screen where customers assembled the actions that drove their campaigns. If it broke, the product was down in the only way that mattered.

I migrated it to Preact in nine batches and a tenth wave, with no downtime and no maintenance window. The most useful thing I learned was not in the plan I wrote before I started.

Why I did not do it in one go

The honest argument for a single cutover is speed. You write the new version, you swap it in, and you stop maintaining two things. On paper it is less total work, and that is true.

The part that is not on paper is what happens when you are wrong. A single cutover has one failure mode and it is total. If the new builder mishandles one saved configuration out of a hundred, you find out from customers, on a screen they were in the middle of using, with no obvious way back.

So I took the slower path for a plain reason. I wanted every step to be small enough that I could undo it without a conversation.

Each batch got its own commit, its own pass through quality assurance, and its own answer to the question of what I would do if it went wrong. That last part is the one people skip. A rollback story is not the same as a rollback button. It is knowing, before you ship, which specific thing you would revert and what the product looks like in the minutes after you revert it.

Nine batches is not ceremony. It is nine chances to be wrong cheaply instead of one chance to be wrong expensively.

Migrate routes, not components

The first real decision was where to draw the line between old and new.

The instinct is to migrate component by component, because components are the unit you think in when you write frontend code. I tried it that way and the boundary got messy fast. A half-migrated screen has two state models in it, and the seam between them becomes a permanent source of bugs that belong to neither side.

Routes turned out to be the cleaner boundary. A route is where the page resets anyway. The browser is already throwing away state at that line, so putting the old and new worlds on opposite sides of it costs nothing extra. New Preact pages mounted into the existing template shell, and everything else in the application carried on as it was.

That let me migrate a whole route family at a time. Profile, then the automation screens, then coupons, media, and the walkthrough. Each one shipped on its own and each one was a real improvement the day it landed, not an instalment on a rewrite that would pay off later.

What I chose not to migrate

Three of the builders stayed on the legacy component library. I did not migrate them, and I did not pretend I would get to them.

They had a constraint the newer code did not share, and moving them would have meant either a much larger rewrite or a compromise that made the new code worse to accommodate the old. Neither was worth it for screens that worked.

What I did instead was formally freeze them. I marked them as legacy in the code, so that the boundary was written down rather than remembered. That sounds like a small thing. It is the difference between a known decision and an unexplained inconsistency that the next engineer has to reverse engineer.

A migration where everything is in progress is a migration with no edges. Closing the door on purpose, and saying so in the code, is how you keep the remaining work finite.

The pattern I did not design in advance

This is the part I would tell a younger version of myself.

Near the end of the work I reached the share tiles. Seven of them, one per platform. Each had its own class, and each class did roughly the same job with different labels, icons, and URL patterns. Seven files, ninety-five percent identical.

I had not planned anything clever for this. I had planned to migrate them the way I had migrated everything else, one after another.

Looking at the seven side by side, the shape was obvious in a way it had not been at the start. The classes did not differ in behaviour. They differed in data. So the data was the abstraction, and the behaviour only needed to exist once.

I replaced them with one provider class and a small readonly object holding the per-platform values, plus one registry entry per platform. Seven classes became one class and seven data records. That wave shipped on 21 April 2026, in a single batch.

The practical result is that adding an eighth platform stopped being a development task. It became one data entry and one registry line.

I want to be precise about the lesson, because the tempting version of it is wrong. The lesson is not that I should have designed the provider pattern up front. If I had tried, at the start, I would have guessed at the variant axis before I had seen all seven cases, and I would have guessed badly. The abstraction was only obvious after the migration had laid the seven implementations next to each other.

Migration is a reading exercise before it is a writing one. Some patterns can only be seen from inside the work, and a plan that does not leave room to discover them will talk you out of the best idea you are going to have.

The same registry approach then got reused for the wider action catalogue and the third party integration tiles. It paid for itself more than once, and it was never in the original plan.

One detail worth keeping

There was a per referral setting on these tiles that behaved differently from everything around it.

Most of the configuration serialised into a single block of settings, which is convenient and fine for values that only ever matter to the screen that wrote them. This one was not like that. Its value needed to be readable and queryable on its own, so it lived in its own column and the provider deliberately did not serialise it with the rest. When it was switched on, the points came from the campaign’s own referral tile at the moment they were needed, rather than being copied and left to drift.

I mention it because the interesting part of a migration is rarely the bulk of it. The bulk is mechanical. The interesting part is the two or three places where the old system was doing something subtle and correct, and the fast version of the migration would have quietly flattened it. Finding those is most of the value of going slowly.

The product did not stand still

The clearest evidence that the batching worked is what shipped alongside it.

During the migration the product gained new action types. Snapchat, WhatsApp, Slack, Pinterest, Vimeo, YouTube, polls, and file upload all arrived while the underlying surface was being replaced underneath them. Customers were not asked to wait for an internal project to finish before they got anything new.

That is the entire argument for incremental migration, and it is a business argument rather than a technical one. A rewrite asks the business to stop receiving value for however long the rewrite takes, in exchange for a promise. Nine batches asked for nothing.

What I got wrong

My migration plan had a blind spot in its structure, and it took me too long to see it.

I had tracked the work module by module. Each module got its own checklist, and each checklist was complete on its own terms. That felt rigorous. It was not.

The problem is that a per module plan can only ask questions about the inside of a module. When an early phase gave one module a capability, nothing in my structure ever asked the cross cutting question: did every comparable module get that same capability? Each checklist could be fully ticked while a gap sat in the space between them, belonging to no module’s list and therefore to nobody’s attention.

I found this out the way you usually do, which is sideways. I was building a piece of work for one module and noticed, while I had the pattern fresh in my head, that a module I had finished earlier had never received the equivalent. The second module’s work is what made the first module’s gap visible. I did not find it by testing it. I found it by building its neighbour.

That is an uncomfortable way to discover something, because it means the finding was luck. If I had not happened to build the neighbour, the structure would never have surfaced it on its own.

What I would do now is run a pattern audit across modules at the end of every phase, not just a checklist inside each one. The question is boring and mechanical: module A gained something in this phase, which other modules should have it too? Ask it at the phase boundary, while the pattern is still fresh, because six weeks later nobody remembers what phase two added.

The wider lesson is about what your process can and cannot see. A checklist tells you whether you did the things you thought of. It tells you nothing about the things your plan’s shape prevented you from thinking of. Those gaps do not announce themselves and they do not arrive in an alert. They have to be hunted deliberately.

What the nine batches were actually for

If I compress ten years of this kind of work into one idea, it is that the goal of a migration is not the new code. The new code is the easy part and it gets easier every year.

The goal is to move a live product from one state to another without the people who depend on it experiencing the move. Everything I have described here, the route boundary, the per batch rollback story, the frozen legacy corner, the pattern found in the middle rather than the beginning, serves that.

Nine batches took longer than one cutover would have. It also meant there was never a day where the product was in a state I could not explain or undo.

Questions I get asked about this

Why nine batches instead of one cutover? Because a single cutover has exactly one failure mode and it is total. I wanted every step small enough to undo without a discussion. Each batch had its own commit, its own quality assurance pass, and its own rollback story, which meant nine cheap chances to be wrong instead of one expensive one. On a surface customers used every day, that trade was not close.

Should you migrate by component or by route? By route, in my experience. A half migrated screen carries two state models and the seam between them becomes a permanent bug source. A route is where the page resets anyway, so the browser is already discarding state at that line. Putting the old and new worlds on opposite sides of it costs nothing extra and gives you a clean edge.

Is it acceptable to leave part of a codebase unmigrated? Yes, if you say so explicitly in the code. Three builders stayed on the legacy library because migrating them would have meant a much larger rewrite or a compromise that made the new code worse. I marked them as legacy rather than leaving them as unexplained inconsistency. A migration where everything is perpetually in progress has no edges, and closing a door on purpose is how the remaining work stays finite.

Should you design the abstraction before you start migrating? Often you cannot. The pattern that collapsed seven share classes into one class and seven data records only became visible once the migration had laid all seven implementations side by side. Had I designed it up front I would have guessed the variant axis before seeing the cases and guessed wrong. Leave room in the plan to discover the best idea partway through.

What does a module by module migration plan miss? Anything that lives between modules. A per module checklist can only ask questions about the inside of a module, so every list can be fully ticked while a gap sits in the space between them, belonging to nobody’s list. In my case an early phase gave one module a capability and nothing in my structure ever asked which comparable modules should have received it too. The fix is a cross module pattern audit at each phase boundary, asking what one module gained and who else needs it, while the pattern is still fresh in your head.

Want to talk about this kind of work?

I am a hands-on senior engineer with 15+ years building and running production systems. I am open to senior engineering and technical lead roles.