Every business is right to be afraid of a rewrite, and refusing one is usually the correct answer. But every year the upgrade is deferred, the payment gets larger rather than later, and a codebase that never pays anything down eventually reaches a version it cannot leave. Then the argument is over. What the deferral actually costs, and how I would run the rewrite nobody wanted and nobody can avoid.
24 March 2025·17 min read
Start by conceding the point, because the people refusing you are not being stupid and most of the literature is on their side.
Joel Spolsky's argument from April 2000 still stands: rewriting from scratch is the worst strategic mistake a software company can make. Netscape rewrote its rendering engine, went roughly three years without a competitive release, and handed the browser market to somebody else. The reasoning underneath is the part that lasts. Old code looks bad because it is full of fixes, and every branch that makes no sense is a bug report somebody resolved at two in the morning. Throw the code away and you throw away the bug reports, and the new system rediscovers all of them in production.
Then the business objection. A rewrite is a very large amount of money spent so that, on the day it ships, the product does what it already did. No feature to announce, no new customer, and the best available outcome is that nobody notices. None of which is an objection to the engineering. The usual failure is that the rewrite is late, and while it is late the old product still needs changing, so one team maintains two systems against a target that moves every month. Most rewrites die of that, quietly, around seventy percent complete.
So the default is no, and it should be. What changes the answer is never a better argument. It is arithmetic that stops working, and the shape is the same in every language.
An application whose oldest running code was written around 2006. A desktop UI toolkit, a runtime version chosen because that is where it landed the last time anyone had budget, and a dependency list nobody has moved in a decade. Then the requirement arrives: it has to run somewhere other than the operating system it was written for. A significant customer's estate is Linux, or the deployment story has to be a container, or sales has already promised it. That is a product requirement rather than an engineering preference, which is the only kind that settles anything.
Now walk the upgrade path and watch each door close. The runtime is years past end of support, and moving to the last release in that family buys back supportability while contributing nothing, because the whole family is tied to one operating system. The UI toolkit looks like better news, since it was carried onto the modern cross-platform runtime, right up until you read the note explaining that it stays single-platform by design, because it draws through that platform's own windowing APIs and always has. A meaningful share of the dependencies have no release targeting where you need to go. And the primitives the application is built out of, the way it serializes state and talks between processes, were not deprecated on the way to the modern runtime. They were dropped.
Anyone who has done this on the Microsoft stack will recognize the specifics, and the specifics are not the point: the Python 2 codebase whose native extension has no maintainer, the Ruby so old that half its gems no longer build, the Java service whose reflective machinery the module system stopped tolerating.
That structure is the wall. It is not one blocker you can cost out and schedule. It is the discovery that the runtime, the toolkit, the dependencies and the process model each fail the requirement independently, and not one of them fails in a way a package upgrade addresses. Replacing the UI does not help while the process model is unportable; replacing the process model does not help while the UI is single-platform. No order leaves a shippable product after each step, which is precisely what incremental modernization cannot fix. What is left is a rewrite, one operating system forever, or telling the customer no.
The obvious question is how anyone let it get there, and the uncomfortable answer is that nobody let it. Every individual decision on the way was correct, which is what makes this worth a section of its own, because if the cause were negligence the fix would be easy.
Ward Cunningham coined the debt metaphor in 1992 and spent years correcting how people used it. His point was not that bad code is debt. It was that debt is the distance between what the code says and what you have since understood about the problem, and that you pay interest on that distance in every change until you close it. So you can write immaculate code against a 2006 understanding of the world and owe just as much.
Now take the decision as it is actually presented. On one side a runtime upgrade with no customer-visible outcome, regression risk across the whole product, and an estimate nobody trusts. On the other, a feature with a named customer waiting. Defer the upgrade. That is the correct call on the information available, and I would make it too. Then make it again next quarter, because the reasons do not change. Nineteen consecutive correct decisions produce an outcome nobody would have chosen and no single person can be pointed at.
So the question is not why teams defer. It is why deferral costs so much more than it looks like at the time. Four mechanisms, none visible in the quarter you skip:
So the payment does not sit still waiting for a better quarter. It grows faster than the codebase does, and at some point crosses from a number you could have paid into a number nobody will authorize. The wall above is not a separate misfortune that befell a team with debt. It is what the interest eventually buys.
Which is why timely paydown cannot be a project. A paydown project competes with features for funding and loses every time, and deserves to, because features have customers attached. What works is a standing allocation spent before the argument happens: a fixed share of every cycle, dependencies moved continuously rather than in batches, one support release at a time with no skipping. None of that needs a business case, which is what lets it survive. And the best single number for how much trouble a system is in is not test coverage. It is how many support windows it is currently outside of, and for how long.
That is the mechanism. This is how it feels from inside the company while it happens, which is worth naming separately, because "technical debt" has been worn so smooth that nobody in a budget meeting hears anything when you say it.
The one a board understands and the one engineers mention last. The maintenance tax is real and it is not the point. The point is optionality: when the market moves, or the biggest customer asks for a capability, the answer is no. Not "that will take a while" - no, because the change would go through a part of the system nobody will touch. It shows up as a deal that quietly went elsewhere, and as a strategy meeting where an obvious option was never raised because everyone had learned not to raise it. You cannot put a figure on the roadmap you stopped imagining, which is why it never appears against the upgrade deferred to protect a roadmap.
With no test coverage and no seams, the cost of a small change is the cost of establishing that nothing else broke, and that is roughly constant however small the change was. Teams start quoting six weeks for two days of work, which is not padding but an accurate estimate of the verification. No velocity chart shows it, because the work that would have shown it never gets proposed. The symptom is that nobody suggests small improvements any more, and nobody notices that nobody does.
An unsupported runtime gets no fixes, which is the easy part to explain. The commoner exposure is duller, because old runtimes make their own security decisions instead of deferring to the operating system. Transport security is the classic: the application negotiates with the protocol and cipher list current when its runtime shipped, however thoroughly the platform underneath has been hardened since. Worse is the build, which nobody counts as exposure at all. When something urgent lands in a component you ship, the question is not whether a patch exists upstream. It is whether you can still produce a signed, tested release this month.
The engineers content to work on it are largely those who already know it, and they are closer to retirement every year. Everybody else takes the other offer, because two years on a stack with no future is two years of career they do not get back. So the codebase that most needs strong engineers is least able to attract them, and the knowledge concentrates in fewer heads in the same direction as the risk.
All of which is a powerful argument, and that is why it gets abused. Any engineer who dislikes a codebase can assemble something that sounds the same. So before using it I would pass a test, in writing, handed to somebody with an interest in refusing.
The rewrite has to be justified by something the business already agreed to sell or is already obliged to do. Cross-platform because a signed contract requires it. An audit finding no unsupported runtime can clear. If the requirement reduces to "we would like a modern stack," there is no case here, only a preference, and it should lose.
Price the alternatives as though you wanted one to win. Run the old application in a VM and stream it. Ship it in a container for the platform it does run on. Put a thin web front end on the same backend for the environments that need one. Every one is worse architecture and at least one is frequently good enough for another five years. An ugly thing that ships in a quarter beats an elegant thing that ships in two years, and the two-year plan is the one cancelled in month fifteen.
A dependency you cannot upgrade because it would take four months is a budget problem wearing a technical costume. A dependency with no version that runs on the target at all is a different category. Write every blocker down and mark which it is. If they all land in the first, you are asking for a rewrite to avoid tedious work.
The new system will not be faster, will not have the features people asked for, and will introduce bugs the old one fixed a decade ago. If you cannot say that in the room where the money is, you are selling rather than proposing. Then price the alternative: the deals that cannot be bid, the audit finding that returns, the engineers who will leave. That answer has to be allowed to come back small, because if doing nothing genuinely costs little this year then it can wait.
Now the plan. I would spend the first months of it on the question everybody wants to skip, because it is the question every failed rewrite got wrong. Not how to build the new thing. What the old thing does.
After twenty years, nobody knows. Not the soft version of that claim, where it means nobody wrote it down. Nobody knows. The people who wrote the original have left, and the behavior the product is genuinely valued for may be a side effect nobody designed. There is a menu item three customers use and one of them is your largest. There is an export format built for a partner integration in 2011 that some regulator now expects to see. And there is a module everybody is confident is dead which runs every night at 2am and has never once failed, which is exactly why nobody has thought about it.
So the first deliverable is not a design. It is an inventory of what the product does, with evidence attached to every line. Four sources, none sufficient alone:
Two things to hold onto. Chesterton's fence: when you find code whose purpose nobody can explain, the honest position is that you do not yet know whether it matters, not that it does not. In old software this is the workaround with no comment, and that is nearly always load-bearing, because something broke once, in production, and this stopped it. And Hyrum's law, which holds that with enough users every observable behavior gets depended on by somebody, specified or not. In a twenty-year-old product that is the main finding: the exact column order in an export, the direction a rounding error has always gone, a temporary file left where a customer's script has read it since 2014. None of it is in any specification, and each generates an escalation the week you change it.
The inventory is only worth anything if it is used to say no. This is a governance step rather than an engineering one, and it is where the program is won or lost.
Sort everything into four buckets, and get the result signed:
The signature matters more than the buckets. This list gets signed by whoever owns the revenue, not by engineering, for an entirely practical reason. In month fourteen a customer will escalate about something in the fourth bucket, and the only question anyone asks is who decided. If engineering decided, it is reversed within a day, the scope comes back, and every subsequent cut becomes negotiable, which means there are no further cuts. If a commercial owner agreed in writing with the usage data in front of them, it holds, and it holds for the other items too.
Only now does any of this become architecture. The goal is not elegance. It is that the work can be delivered in pieces that each stand on their own, because a rewrite that only pays out at the end is the one that gets cancelled before the end.
The pattern is Martin Fowler's strangler application, described in 2004 and named for the fig that grows around a host tree until it can stand without it. The new system runs alongside the old, capabilities move across one at a time, both live throughout, until nothing is left in the middle worth removing. It is the only shape I have seen work at this size, for a reason that is not technical: it produces something to show every few weeks, which is what keeps a program alive across a change of sponsor.
Concretely:
The ordering this produces is the reverse of what everybody wants to do. Logic behind an API first, with the old UI still on top and nothing visibly different to anyone. Then a new UI against that API, for one capability. Then capability by capability. The visible part comes last within every slice.
The technology choice is the part everyone wants to start with and the part that matters least, provided it is made against the requirement that forced the rewrite rather than against taste.
Does it target everything in the requirement? Check that first and let it end the shortlist, because it routinely gets checked last, after a comparison table has already made somebody's favorite look good. The first-party option is the comfortable default and is frequently the one that does not cover the platform you were forced onto: vendors build cross-platform tooling for the platforms they care about selling into, and that list is not your list. The question worth asking before any of it is whether the answer is a desktop application at all, because if the logic is going behind an API anyway, a browser client is cross-platform by construction. It is worse for offline operation, hardware access and the dense keyboard-driven screens that professional users get fast on, and any of those may be decisive. The point is that it gets answered on evidence rather than skipped because the thing being replaced happened to have windows.
The delivery model is what separates a program that survives a change of sponsor from one that does not. Easy to state and hard to hold: the new system gets in front of real users early, in pieces, doing something they can see.
The tempting plan is to build the data layer, then the services, then the UI. It parallelizes cleanly and delivers nothing until the very end, which turns the whole program into one bet settled on one date. Slice vertically instead: one complete capability, storage to screen, that a real person uses to do a real part of their job. Choose the first to be small, used often enough to give feedback within weeks, and not the one that ends the company if it misbehaves, because its job is to prove the seams, the deployment and the accuracy of your estimates.
Users will have the old client and the new one open at the same time, for a long period, quite possibly years. Design for it deliberately: one login, shared data, and a published statement of which system does what this quarter. The instinct is to treat it as an embarrassing transitional phase and say as little as possible, and that produces the worst version, where people meet the boundary by surprise instead of being told where it is.
Percent-complete on a rewrite is fiction, and a specific kind: the remaining work is by definition the work nobody has examined, so the number is most wrong exactly when it is most reassuring. Track how many real customers are doing how much of their real work in the new system. It is a harder number to make look good, which is the reason to use it.
The old system does not politely pause while you build, and every change request will arrive sounding reasonable because it will be. The workable policy: security and regulatory fixes go in without argument, anything else only if the affected capability has not yet been rewritten. Publish it at the start, because relitigating it request by request is how a team ends up building two products and finishing neither.
Decide in advance what ends the old system: a usage threshold, a date, or both, with a named owner. Decide it in month one, because the last few percent of users will not move voluntarily and by then nobody will want the argument. Without a trigger the old system is still running five years later, which means the rewrite replaced nothing. It added a second product to maintain.
Where I think this is weakest, because a plan that arrives with only reasons to believe it is a sales deck.
The discovery phase is expensive and produces no software, and "trust me, the inventory pays for itself" sounds exactly like what it sounds like. The best mitigation I have is that two of its outputs, the telemetry and the logic-behind-an-API work, improve the existing product whatever gets decided afterward. Strangling also costs more in total than a clean rewrite would, if the clean rewrite worked: you build adapters whose only purpose is to be deleted later, and run two systems for years while paying for both. Anybody selling incremental delivery as the cheaper option is wrong. It is not cheaper. It is more likely to finish.
And all of it rests on the claim that the wall is real in the case in front of you. I have also sat in rooms where a very similar argument was made, sincerely and fluently, about a system with six good years left in it, by people whose actual position was that they did not want to work on it any more. I do not exempt myself from that, which is why the test comes before the plan rather than after it.
Condensed, in the order I would actually do them.
The ordering is the argument. Almost everyone treats a rewrite as a technology decision and opens with the framework comparison, which is the one choice that can be reversed later and the one that absorbs all the attention. The decisions that cannot be reversed are what you keep, what you drop, and who put their name to it.
And for anyone not yet in this position, which is most people reading it: none of the above is the expensive part. The expensive part already happened, one entirely defensible quarter at a time, years before anybody said the word rewrite out loud. If your product still has a live upgrade path, all of this is avoidable for a fraction of a cycle a year spent on work no customer will ever thank you for. That is a terrible pitch and it is the actual conclusion.