Back to work

Case study

Native build engine for a multi-app fleet

Most of the engineering I'm proudest of is invisible: infrastructure that makes a team's day quieter rather than a feature a user can see. This is one of those systems — a native foundation that five React Native apps share instead of each maintaining its own. It's also a system I built twice, because the first version taught me what the second one needed to be. Here's what it does, why it exists, and what I'd defend about it out loud.

The problem

I was maintaining five React Native applications across iOS and Android. Every time we upgraded React Native or a shared library, I had to repeat the same native procedure five times — the same plumbing, the same edits, the same room for five slightly different mistakes. The pain wasn't any single setup; it was that the cost multiplied by five and recurred on every upgrade. That is the shape of a problem worth abstracting: repetitive, multiplying, and not solvable by being more careful.

The constraints

This predates Expo being the recommended default for React Native. Adopting it wholesale wasn't an obvious or sanctioned move at the time. On top of that, the team needed full control over the native layer and minimal dependence on third-party tooling — the foundation had to be something we owned, not a black box we rented. So the real requirement wasn't "make setup convenient." It was "give us a controlled, owned native foundation that five apps can share." My job was to turn that requirement into a working system.

Generation 1 — a bare-workflow foundation

The first version touched no framework abstraction at all. I kept the native iOS and Android projects as hand-maintained folders, but I moved them inside the engine library instead of letting each app carry its own copy. I wrote a custom prebuild step that read each app's configuration — icons, assets, and the rest — and injected it into the folders inside the installed library; each app then linked to those folders via symlinks. One native foundation, many consuming apps.

The hardest part of this generation was trust in reproducibility. When I started, I wasn't sure it would even come together — it felt possible but beyond my patience and stamina to finish. The thing that genuinely scared me was the question will this work on someone else's machine? "Works on mine" is worthless for shared infrastructure. Our developers run across macOS, Windows, and Linux, so I stood up a virtual machine for each, ran the scripts on all three, and verified the output with the team lead on other people's machines until the "something will break" feeling stopped being true. That cross-platform verification — not the scripting — was the real work.

The teardown

Over time the bare-workflow approach aged. It accumulated technical debt and started to feel like a foundation I'd have to fight rather than build on. So I threw it out and rewrote the engine from scratch — deliberately, while it still worked. What I refused to lose in the rewrite were the properties the organization actually depended on: simple per-app configuration, fast roll-out across multiple apps, and fast delivery to the stores and to QA. The implementation changed completely; the invariants did not.

Generation 2 — an Expo wrapper that keeps the same philosophy

By this point Expo had matured, and an owned layer on top of it was defensible. My first instinct was to carry the old philosophy straight over: keep using symlinks, just point them at the folders produced after Expo prebuild instead of hand-maintained ones. I recognized fairly quickly that this was the wrong path, dropped it, and moved toward Expo config plugins as the mechanism for supporting our native libraries. The engine became a set of scripts and plugins around Expo — without changing the core idea that defined both generations: centralize all native code for every app in the company in one place.

Today the engine is four parts:

  • Configuration — a single source for the Expo app config, Metro config, and environment handling (dev / staging / prod). A consuming app declares a few lines of glue instead of re-authoring the whole native config.
  • Config plugins — custom plugins that apply our native requirements to the iOS and Android projects at prebuild, replacing what would otherwise be hand-edited native files in every app.
  • Native modules — shared native modules with extension points, so apps consume the common ones and can still add their own. The current set includes a native logging module that feeds server-side analysis, and a module for exercising our crash-reporting and log-aggregation integrations.
  • Build / release CLI — a custom command-line tool that orchestrates build, run, OTA, archive, and store/QA distribution, locally and through the cloud build service, across five build variants. It handles signing wiring, symbolication uploads, AdHoc OTA distribution, and incremental artifact distribution to object storage.

Two capabilities I added because nobody asked

Production symbolication. I noticed we weren't uploading source maps and debug symbols to our crash-reporting and log-aggregation backends on builds and OTA updates. Nobody had filed that as a problem; the team just tolerated debugging production the hard way. I closed the gap by wiring symbol upload into the build and OTA flow automatically, so production stack traces became readable by default.

Sub-second debug launch. Our developers were used to a yarn android / yarn ios workflow that triggered a full native build on every run — wasteful, since the native layer rarely changed between runs. The engine ships prebuilt debug artifacts where launching only requires swapping the JS bundle, so the app starts in about a second instead of rebuilding each time. That compresses the feedback loop the whole team lives in all day.

Results

The engine's value shows up in three different dimensions:

Native-config footprint. Per app, hand-written native configuration shrinks from roughly 1,890 lines across ~30 files to a ~30-line, 3-file glue layer — about a 98% reduction. Across the five apps, that's on the order of 7,600 lines of pure copy-paste duplication that simply never comes into existence. (Raw line counts measured against the current repo, treat as order-of-magnitude, ±10–15%.)

Setup time. Per app, greenfield native setup drops from roughly a week-and-a-half of work to about a day — a delta I'd defend at 4–9 developer-days saved per app, even at the pessimistic end. Across five apps that compounds to roughly one to two developer-months. This is an expert estimate by component, not a stopwatch figure — and it is after amortization: building the engine was a one-time cost that the first app roughly pays back, so the honest framing is "after the first app, each additional app is about a week of native setup we don't repeat," not "we saved a week on every app from day zero."

Iteration speed. Production became debuggable by default (automatic symbolication), and the day-to-day launch loop dropped from a full rebuild to a sub-second bundle swap.

And the change that started the whole project: a shared native change — an RN or library upgrade — is now made once in the engine and propagates to all five apps, instead of being repeated five times by hand. This applies to the shared native layer; product code is unaffected. It isn't a blanket "5× less work," it's "one change instead of five for the native foundation."

What the engine deliberately does not do

It doesn't acquire certificates or keystores (irreducible), doesn't replace per-app secrets or the per-app build matrix (the engine standardizes their shape, not their existence), and doesn't touch product code, screens, or QA. Stating the boundary is part of the point: the engine is a native-foundation layer, not magic.

Why it mattered

The reason this is more than tooling is the timing. I committed to a shared, owned native foundation before the ecosystem offered one out of the box — and then, when the ecosystem caught up, I had the discipline to tear down my own working v1 and rebuild on the matured platform without losing the invariants the business relied on. The same problem, solved twice, against a moving ecosystem. The current migration toward an Expo-native compatibility layer is the next step of exactly that arc.

Back to selected work