Building Ledger Wallet: Upgrading React Native in a high-velocity repository
In Part 2 of this series, discover the engineering strategy behind Ledger Wallet updates. Learn how Ledger transformed its mobile and desktop apps without impacting the hundreds of engineers collaborating daily on the main frontend Monorepo.
Side Channel – Where Ledger engineers talk about how they design, build, and scale our systems.
For most of autumn 2025, I started the day the same way: open the branch, pull main, resolve conflicts, push, and wait for the pipeline. More often than not, by the time it came back, other pull requests had merged, and I had fresh conflicts waiting, making the run I had just waited for already obsolete.
The branch turned on React Native’s New Architecture (RNNA) for the Ledger mobile wallet. It was open for 82 days, rebased 119 times, and by the time it merged, 933 other pull requests (PRs) had gone in ahead of it.
We did it for two reasons. The performance, which we got: 300ms off startup on Android, and a far smoother user experience. And, more importantly, the old architecture was going to be frozen anyway. React Native announced they’d stopped developing the legacy renderer, and the compatibility layer that carries old libraries was scheduled to go. Even if the first point made it worth doing, it would stop being optional.
The arithmetic
At that time, Ledger Live was a monorepo of ~150+ packages and 4 applications. A desktop app and a mobile app built from the same libraries, which is the point of the arrangement and also where the difficulty starts: ~50 of those packages were related to React Native.
Also, at that time, every pull request ran a gate that took between 30-40 minutes, sometimes 80 minutes… yes, painfully it was! Fortunately, today our gate run became a predictable median of 23 minutes. Anyway, when you have 933 merges that went in ahead, it means you roughly have 1PR merged every 30min, and useless to say that for a PR with 402 files changed; that is a race you have already lost: you resolve, you push, you wait, and by the time the answer comes back, what you resolved against has moved.
We needed to change the tires of a centipede car while it was moving.
Why couldn’t we do it gradually?
Generally, the usual advice for a risky migration is to put it behind a flag, roll it out to 1%, watch the dashboards, and turn it up, and indeed, that advice assumes the change is a feature.
RNNA is the substrate your/our app is built on. Since React Native 0.76, you get Fabric, TurboModules, and the bridgeless runtime from a single property switch. A build-time switch, not at runtime, meaning the compiler bakes it into the binary, so your app is either fully on RNNA or not, simple as that. No halfway, no per screen, no per user, and nothing you can change once the build exists.
We could have hedged in our own code, keeping both paths alive and branching at runtime. We did not. Supporting both renderers means adding a huge amount of complexity in the exact weeks you are trying to make everything stable. The point was to be on the new architecture, not to be able to run both.
Yes, app stores will meter a release for you without any runtime flag, which is how Shopify staged theirs, but we don’t do staged rollouts, so our process doesn’t include that.
So, when you cannot stage the risk after merging, you have to absorb it all beforehand. That is what the 82 days were! We actually had to change all the car’s tires simultaneously.
What Fabric actually broke
Among all the issues we had to tackle, the Fabric renderer was the sneakiest one. The loud breakages are the easy ones. A native module that has not been migrated fails at startup; you find it in a minute, and the guides cover it well.
The expensive ones are the APIs that still exist, still type-check, still pass your unit tests, and E2E tests. Here’s one of the worst we had. Something as simple as a pressable was fine on the old architecture and not on Fabric. Measuring where a view sits on screen had synchronisation issues with animations: the button was measured before it had been animated for the first time, so if it appeared from a bottom panel, the internal press check worked from a stale position and the button was randomly considered off screen. And worse, the race only went wrong when a real user was touching it.
In the end, we carry a patch against the touch-handling path for every pressable element in the app, calling a custom native module that can read the right values. Every React Native upgrade from here starts by checking whether that file still looks the way the patch expects because the fix took many versions before landing very recently.
Nothing in the migration guides prepares you for this class of problem, and that is not an oversight on their part. It only shows up in applications old enough to have accumulated code that depends on the old semantics, which is precisely the set of applications least able to follow the happy path.
How we actually landed it
A percentage rollout is a way of borrowing attention. You ship to a slice of users, they exercise the build without being asked, and your dashboards tell you whether it went badly. With no ramp, we had to buy that attention instead. But first the branch had to stay landable at all. And with everything above as a given, it indeed came down to a few habits, none of them interesting on their own.
Rebase every morning, and again whenever the pipeline forced it. That is where 119 comes from. It was a discipline before it was a reaction: the first thing I did each day was pull and resolve, because a day of divergence in this repository is not a day of work to catch up on; it is several.
Squash the branch back down, repeatedly. This is the one decision I would both defend and warn people about. Rebasing a branch made of many commits against a mainline that moves every half hour is slow, and it gets slower every week. Collapsing everything to a single commit made each rebase survivable. The price is that the history of the migration is gone: what broke, in what order, and what we tried first is not recoverable from git. We traded the record for the ability to keep going. At the time it was the right trade, and I would still rather have the record.
Fix everything inside the branch; never merge and repair afterwards. That sounds like discipline, and it is really just the success criterion. For a user, a foundation migration has to be invisible. Nothing new, nothing missing, and ideally a little faster. Anything else is a regression we chose to ship. Once that is the bar, merging with known breakage stops being an option.
Which brings back the attention we had to buy. I called for real test sessions. Our QA engineers wrote the plan. The whole team turned up to run it, and the full plan was executed on iOS and Android.
The session format is the whole technique. The team came together for 90 minutes to 2 hours, where everybody worked through the shared plan, and everybody also exercised the parts of the app they personally use, which is where a written plan stops helping, because here at Ledger most of us are users of our own product too. Dogfooding is in the company’s DNA.
Findings were centralized into a shared page as they happened, and at the end of a session, our QA engineer marked every one as either a new bug or something already broken in production. A total-surface migration surfaces every pre-existing defect at the same moment as its own, and without that pass you are staring at a hundred problems with no way to tell which ones you caused.
Those sessions caught hundreds of regressions that no test caught. And credit to the Ledger Wallet team, who came to the rescue after these sessions!
Knowing when to stop
None of that tells you when to stop, which was the harder question. What made us confident was not the bug list reaching zero. It was coverage of the paths people actually use: the sessions had been through all the most-used flows, and most of the rest, so the remaining risk had a shape we could describe. Whatever was still hiding would be an edge case; it would surface quickly once it met real users, and we were ready to turn a fix around fast.
On the 18th of November, QA signed the migration off in writing as ready to merge, with a confidence rating of orange rather than green. That is all orange meant. Not “we are nervous”, but “we are not claiming this is free of bugs”. Green would have been that claim.
And here is the part I would change. I wouldn’t do this upgrade again with that little coverage, and I don’t mean only unit tests. We had no visual regression coverage, and nothing automated exercising the third-party integrations or the drivers, like Bluetooth. Which is why the answer to every open question was another manual session. Verifying every flow, every render, and every behaviour by hand is not a methodology; it is a bill. We paid it because we had no alternative, and building that alternative would have cost less than the migration did.
Yoann SITBON – Staff Software Engineer
Kevin LE SEIGLE – Engineering Manager
Raphaël TAHAR – Principal Engineer