Checkout is live. It's under QA right now, and every hour feels longer than it should. Not because I'm nervous about the code — I'm fairly confident in what we shipped. It's because checkout is where BrowserStack makes money, and we just replaced the entire thing.
That weight is appropriate. It kept us honest throughout.
Why this existed
BrowserStack's checkout was a Rails monolith in the way that actually means something: the frontend and backend lived in the same ERB files. Not adjacent. The same files. View logic, business logic, jQuery DOM mutations — layered on top of each other over years of accumulated feature work.
That's not unusual for systems of this age. It's how a lot of SaaS products started: ship fast, validate, grow. The architecture follows the velocity. By the time you have money and scale, the thing that got you there is starting to cost you.
The cost here was experimentation. The growth team needed checkout to be a surface they could run experiments on — pricing nudges, social proof signals, payment flow changes. With ERB and jQuery, every experiment required touching both the Rails views and the client-side DOM manipulation at the same time. No component isolation. High coupling. High risk. Every release felt like defusing something.
There were also two separate checkout pages — one for free trial users, one for paid users — with duplicated logic across both. Every change had to be applied twice, tested twice, and hoped to be consistent.
What we were actually solving
This wasn't a "let's use React because it's modern" migration. That framing would have killed it.
The actual problem: the team couldn't move fast on checkout without breaking something. And they needed to move fast on checkout because that's where conversion experiments happen. Every week of slow delivery on a checkout experiment is a week of revenue data you don't have.
The constraint was equally real: checkout is zero-tolerance for regressions. A broken free trial sign-up, a failed payment form, a UI state that doesn't update — these are not bugs you fix in a follow-up sprint. They cost conversions, sometimes immediately.
So the problem was: increase experimentation velocity without increasing regression risk. Those are in direct tension on a tightly coupled monolith. The only clean solution was decoupling.
What we considered
The paths looked like this:
Incrementally modernize the ERB/jQuery layer. Move jQuery to a more structured pattern, introduce some component discipline. Least risky on paper — no re-architecture, no parallel systems. But this addresses the symptom (messiness) rather than the structure (coupling). The growth team would still be blocked because server-side rendering means state changes require round-trips. Experimentation surface stays limited.
Rewrite checkout entirely, run in parallel. Full React rewrite, new backend API layer, new everything. Complete control, clean slate. But also: two checkout systems to maintain until cutover, higher delivery risk, and months of duplicated QA effort. Given that checkout was already serving both free trial and paid users on two separate pages, adding a third "in-progress" system creates more surface area, not less.
Migrate the frontend layer to React, move Rails actions to APIs in parallel. Not quite a rewrite — more a structured decomposition. Replace ERB templates with React components, extract server-side logic to REST APIs, unify the two checkout flows into one. The backend logic stays in Rails (where it belongs), but gets a clean API contract instead of being embedded in view templates. This is the path that actually attacks the coupling.
We took the third path. Not because it was safest — it required understanding every flow (free trial, paid, expired, mobile, desktop, v3/v4 pricing) deeply enough to unify them. But because it was the only option that solved the actual problem and produced something the growth team could actually use.
How we built it
The first non-trivial decision was unification. Pre-migration, free trial users and paid users hit different checkout pages. Different component trees, different server actions, different states. Merging them meant understanding every divergence — where the flows were truly different by design versus where they'd drifted apart by accident over years of separate development.
A lot of the divergence was accidental. Different teams had added things to each page without updating the other. Merging forced a cleanup that probably should have happened earlier.
The component breakdown was deliberate: small, configurable, isolated pieces. Not because we were following React best practices for the sake of it — because the growth team needed to be able to wrap a component in an experiment variant without touching the surrounding layout. If your components aren't isolated, you can't do that safely.
Backend logic got extracted into unit-testable functions as part of the decoupling. This was not optional. With ERB, business logic was essentially untestable in isolation because it was entangled with rendering. Extracting it to APIs meant we could write unit tests that didn't depend on view state. That mattered for confidence in a zero-regression migration.
I owned about 80% of the deliverables end-to-end — the component structure, the API contracts, the unified flow design. The sub-squad was small. That meant moving fast and making decisions without a lot of consensus overhead, which was fine because the tradeoffs were clear. It also meant I needed to hold the whole system in my head throughout, which is where the complexity actually lived.
What we gave up
Speed in the short term. This migration took meaningful time, and during that time we were not shipping new checkout features. That's a real cost to a growth team.
We also made a bet on the unified flow. Keeping two separate checkout pages was defensible — they served different user types, and maybe those user types genuinely needed different experiences. Our read was that the differences were mostly accidental rather than intentional, and unification would be cleaner long-term. If we were wrong about that, we'd pay for it with complexity in the unified component. So far we're not wrong about it — but it was a judgment call with incomplete information.
There was also the jQuery removal. Some of the existing DOM manipulation logic had implicit behavior that wasn't documented anywhere. Finding it required reading code carefully rather than relying on tests, because the tests barely existed on the client side. That kind of archaeological work is slow and easy to get wrong.
What's different now
The new checkout is one page, React-driven, API-backed. Bill details, plan summary, payment input — all in isolated components. The payment form renders client-side. State updates without round-trips. The flow handles free trial users, paid users, expired accounts, mobile and desktop contexts, v3 and v4 pricing plans — all from the same component tree, differentiated by configuration rather than by separate pages.
The growth team has a surface they can experiment on. A/B test a pricing nudge: wrap a component. Add a social proof signal: add a component. Change the payment flow for 3DS: isolate the payment component and change it. None of these require touching unrelated code.
The QA cycle before release was rigorous. That was intentional — we needed to verify every flow across every user type before cutover. It took time. It was the right call.
We're past the cutover now. The numbers look right. The regressions haven't materialized.
The thing I keep thinking about
The checkout was a monolith not because anyone chose to build a monolith. It was a monolith because each addition made sense in context, and the coupling accumulated gradually. By the time the cost became visible, it had years of history behind it.
Migrations like this are expensive not because they're technically hard — this one wasn't. They're expensive because you're paying down debt that compounded over a long time, and you're doing it while the system is still in production, still serving users, still generating revenue. You can't stop the clock.
The real lesson isn't "use React instead of ERB." It's that the architecture of a system shapes what the team can do with it. The old checkout made experimentation expensive. The new one makes it cheap. That's not a frontend technology choice — it's a product velocity choice that happened to require a frontend migration to execute.
The growth team can now ship experiments on checkout in days instead of weeks. That gap — days versus weeks, compounded over a year — is what the migration was actually about.