I’ve built React applications for Credit Karma, Walmart Labs, and Cigna, plus a multi-tenant platform serving several brands off one codebase. The hard problems at that scale are never JSX or lifecycle methods. They’re architecture decisions that looked fine on day one and compound for three years.
The 2.8MB bundle
A Fortune 500 marketing site I worked on shipped a 2.8MB initial bundle. We got it to 400KB in two days and time-to-interactive dropped about 60%.
The fix was route-based splitting with React.lazy, which is the boring answer. The better question is how it got to 2.8MB, because nobody decides to ship that.
It happens structurally. A single entry point imports a router, the router imports every route component eagerly so it can build its table, and every route component imports its own dependency tree. Now your bundler sees one connected graph with no natural cut points and does the only correct thing available to it: ships all of it. Nobody made a bad decision. The module graph just had no seams.
That’s why React.lazy works. It doesn’t optimize anything. It introduces a dynamic import() that the bundler can treat as a split point. You’re not making the code smaller, you’re giving the tool permission to cut.
The generalization: when a build output is bad, examine the shape of the dependency graph before examining the code. Bundle size is usually a graph reachability problem wearing a performance costume. The bundler is computing a transitive closure over your imports, and if that closure has no articulation points, there is nothing to split on. React.lazy manufactures one.
State management, the rule that survives contact
Every enterprise React codebase fails one of two ways. Either Redux for everything, including local UI state that should have been useState, or a religious objection to state libraries and forty-line prop-drilling chains.
The rule that’s held up for me: local state by default, lift only when a second component genuinely needs it, and reach for a global store only for things that are actually global. Auth, feature flags, theme. That’s roughly the whole list.
The reason it’s hard to follow isn’t that it’s subtle. It’s that “will another component need this?” gets answered optimistically during a design discussion and pessimistically during implementation, so state drifts upward and never comes back down. The discipline is in the downward direction, and nobody schedules that work.
Testing, and what to skip
The distribution that worked: heavy investment in integration tests with React Testing Library, minimal unit tests for pure utilities, a thin layer of Cypress for critical flows.
Skip snapshot tests entirely. They fail on every legitimate change and pass on most real regressions, which is backwards from what you want. A test that’s noisy in the common case gets updated reflexively, and a test people update without reading isn’t a test, it’s a ritual.
Integration tests earn their keep because they exercise the thing you actually care about, which is whether a user can complete the flow, without coupling to implementation details that will change. Testing Library’s whole design encourages that by making it awkward to reach for internals.
The one that matters most
Code review discipline outperforms every technical choice on this list.
A consistent codebase where everyone follows the same patterns beats a clever one where each developer invented their own approach, and it isn’t close. At scale you’re not optimizing for the best code, you’re optimizing for the code a new team member can read in eighteen months when everyone who wrote it has moved on.
Boring, consistent, and predictable wins. That was true in the React codebases and it’s been true in every system I’ve built since, including the Rust and Go work where the temptation to be clever is much stronger.
That lesson is also why nSelf ended up with 248 commands that all behave the same way rather than a smaller, cleverer surface. Consistency is a feature that compounds; cleverness is a cost that compounds.