How I’d keep a million-line frontend from rotting — modules, ownership, and the refactors I skip
Owen MacAllister
September 18, 2026
A million lines of React is not a stack problem. I have been asked to “move us to Vue” or “to the new compiler” as if the rot were a framework version. The rot was a folder named components that everyone owned and a shared store that knew about billing, search, and a modal from 2019. The line I use now is a boundary line, not a bundler line.
Here is how I would keep a huge frontend from rotting: modules that match product ownership, a rule about imports, a few refactors I will fund, and a longer list I will skip even when they look modern.
The line I use
If two teams cannot ship without merging into the same file, you do not have two teams. You have a queue. I draw a boundary where a squad can own a route tree, a data contract, and a package they can version — even if the deploy is still one app. Module federation, npm workspaces, Nx, a apps/ and packages/ split — I do not care which, if the import rule is real.
The import rule I actually enforce: a product package may import design-system and auth-sdk. It may not import another product package’s internals. It may not import a utils barrel that re-exports the world. Shared code is extracted upward when two owners agree, not when someone was in a hurry. I fail CI on the illegal import. A wiki rule is not a boundary.
Ownership is a CODEOWNERS file that pages the right Slack, not a slide. If packages/billing-ui has no owner, it will rot. If it has three owners, it will rot slower and more politely. One owner. A deputy. That is enough.
Modules that stay alive
I name packages after product words: checkout, catalog, account. I do not name them after layers: containers, hooks, smart-components. Layer folders become dumping grounds. Product folders become places you can delete.
Each package gets a public index that is small and mean. Everything else is internal. Agents and humans both love to import a deep path because autocomplete offered it. I ban deep imports in lint. The public index is the API. A breaking change to the index is a version bump and a conversation.
State stays local until it cannot. A global Redux or Zustand god-store is how checkout learns about a tooltip. I will use a server cache — TanStack Query, Relay, RTK Query — keyed by resource, and I will not put view state in it. URL state for filters that should be shareable. I have deleted 2,000 lines of store by moving a filter to the query string. Nobody missed the store. They missed the shareable URL they did not know they needed.
Design system is a package with a release train, not a folder people edit in the same PR as a feature. If you cannot version a button, you do not have a system. You have a coincidence.

The refactors I fund
I fund a boundary that unlocks two teams. I fund deleting a god-store. I fund replacing a 4,000-line page with a route-level split and a lazy boundary that matches the UX. I fund a test around the money path — Playwright for checkout, not for every tooltip.
I fund type-safe API clients generated from the contract we already publish. I do not fund a second client “because the types were nicer.”
I fund a delete. The highest-ROI refactor in a million-line app is removing a product nobody ships. Dead routes still compile. They still confuse agents. They still get “fixed.”
The refactors I skip
I skip the framework migration that is really a rewrite. React 18 to 19 in place, incrementally, yes. React to Solid because a blog was excited, no — not at a million lines.
I skip CSS-in-JS to Tailwind to CSS modules as a year-long tour. Pick one for new code. Leave the rest until the file is already open for a real reason. Consistency is a luxury of small apps.
I skip “clean architecture” folders — domain, data, presentation — copied from a backend talk. Frontend boundaries are user journeys and ownership, not concentric circles that all import axios.
I skip microfrontends until the org pain is real. An extra deploy and a shared-nothing runtime for two teams who sit together is a tax. I will split packages first. I will split deploys when the release train is the actual queue.
I skip rewriting class components that are stable. I skip Storybook for every dumb wrapper. I skip 90% coverage on presentational noise. I skip the big-bang design-system swap. I skip formatting the entire repo in one PR so git blame dies.
I skip letting an agent “clean up the frontend” in a 40-file PR. That is how boundaries die. I give the agent one package and a lint rule.

How I would spend the first 90 days
Week 1–2: map owners. CODEOWNERS. Find the files with the most authors in git. Those are the queues. Pick one to split.
Week 3–6: extract one product package. Illegal-import lint. A CI job. Do not migrate the world.
Week 7–10: delete or quarantine dead routes. Fix the god-store only where the new package touches it.
Week 11–12: write the two Playwright journeys that are the business. Stop. Show the lead a before/after of merge queues, not a slide about architecture.
I would not start with a new bundler. Bundlers are not why you merge-conflict on Button.tsx.
Performance is a boundary issue too
A million lines often means a million-line main bundle because everything imported the barrel. Code-split by route that matches ownership. Measure with a real user path, not a homepage lighthouse flex. I have seen teams celebrate a homepage score while checkout loaded the admin charting library. The import graph was the performance bug. The framework was innocent.
I keep a budget for the checkout JS. CI fails if the package imports a forbidden heavy. That is a boundary with a kilobyte number. Engineers respect numbers more than sermons.
Agents inside a million lines
A model will “help” by editing the shared barrel and three product packages in one pass. I forbid that in the rules file: one package per PR unless the change is the design system’s public API. I also keep the public index generated or linted so a new export is visible. Invisible exports are how the next agent deep-imports and the boundary becomes folklore again.
I give the agent the CODEOWNERS hint and the illegal-import command. I do not give it the whole app as context. Root-level chat in a million-line frontend is how you get a new hooks/useEverything.ts. I have deleted that file more than once. I would rather delete a rule than delete a weekly hook.
What I tell the team that wants a rewrite
Rewrite if the framework is abandoned and you cannot hire. Rewrite a slice if the UX is a new product. Do not rewrite to escape rot. Rot will follow you into the new repo if the ownership is the same. I have watched a greenfield Next app become the old CRA app in nine months because components/ was back and CODEOWNERS was “later.”
A million lines of React can live. It lives when modules are product-shaped, imports are illegal across owners, refactors are deletes and splits, and nobody gets a medal for a fashionable rewrite. I skip the medal. I keep the line. The line is: can this squad ship without asking another squad to merge first? If yes, the frontend is not rotting. If no, pick a boundary, not a new stack. I will help you draw it. I will not help you migrate the atoms for sport.
That is how I would keep it. It is janitor work and politics. Frontend scale is mostly politics with a bundler. Admit the politics. Then the bundler gets easier. I have never seen the reverse work for more than a quarter, which is about how long a rewrite stays inspiring.
If you need a sentence for the steering committee: we are not a React shop with a million lines. We are five products in one deploy, and we will keep them from becoming one file. The million lines can stay. The one file cannot. I will spend political capital on the file. I will not spend it on a new renderer. The renderer is not why your senior engineer is reviewing a tooltip in a billing PR at 7pm. The missing boundary is. Draw it. Lint it. Own it. Skip the rest until that is true. I have given that speech. It lands better than a framework comparison. It also happens to be the only speech I can still give without lying. After that, we pick one package and we split it. Talk without a split is just another slide.