SootSim Deep Dive

Real React Native comes to the web. The trick? Going much further than expected.

SootSim runs real React Native apps in the browser with the same Metro bundle you use for your React Native app. It’s neither a WebView nor a streaming VM. It’s a clean-room implementation of everything it takes to make React Native look and feel real: the React reconciler, Yoga for layout, the responder and gesture system, animations, scroll physics, iOS and Android UI, Fabric, and all the surprising little things React Native does in practice.

We run pixel-diff comparisons across everything: the core and the top 1,000 native libraries in the React Native ecosystem. If the difference exceeds one percent, we fix it. SootSim’s four-worker architecture mimics how real iOS apps work and keeps frame drops rare. That’s the point: accurate enough to trust, fast enough for daily use.

The obvious question is: why?

Native simulators are painful, expensive, and slow. That pain doubles when you count both Android and iOS.

SootSim boots both iOS and Android in seconds, with no build to think about. It needs no Xcode, no Mac, and no Java. It runs in a few hundred MB of memory, anywhere: Linux, Chrome, iOS Safari, or an isolate.

It also has abilities native tooling lacks: its CLI is much faster than native alternatives, behaves consistently across platforms, and connects directly to React and React Native. It even hooks into react-native-screens, Reanimated, and react-native-gesture-handler to give your agents deeper insight and smarter settling logic.

Skipping native builds can save hours in CI. Even setting that aside, SootSim is the fastest React Native test runner:

Same Bluesky flow, three runnersrecorded wall time
SootSim
18.5s
Maestro
22.8s1.2× slower
agent-device
47.4s2.6× slower

Home, search, profile, and composer in the Bluesky app, driven end to end by each runner on the same machine. The simulator runners start fully warm: simulator booted, app installed and signed in, Metro bundle compiled. SootSim restarts its guest runtime inside the timed window.

The engine

This article is a deep dive into how it works. SootSim has evolved quite a lot since its inception.

We start with our own React reconciler. Each SootSimNode carries resolved React Native style, a Yoga node, children, layout, accessibility info, and host-instance fields expected by Fabric libraries.

We use Yoga in WebAssembly, the same layout engine React Native uses. To render, we use CanvasKit, a WebAssembly build of Skia, the graphics engine used by Chrome and Flutter. Text is shaped with real font metrics, fed back into Yoga for measurement, and finally painted by Skia.

SootSim captures React Native’s thread model, too, which is why we have four execution contexts:

The tenant worker runs the guest bundle, React reconciler, authoritative app tree, and Yoga. It owns JavaScript-visible state and committed layout, but paints nothing.

The shell worker is the native UI authority. It owns gestures, scroll state, native-driven animation, the app mirrors used for input, and platform surfaces such as the home screen, keyboard, alerts, sheets, and status bar. It paints those shell surfaces, but no app pixels.

The compositor worker owns the two app canvases, its own CanvasKit runtime, and its own animation clock. It paints app pixels, samples them so shell chrome like the status bar can pick a legible contrast, and produces snapshots for switcher cards and live-blur backdrops.

The host page owns browser integration: the DOM canvas stack, pointer ownership, worker lifecycle, capture, recording, and the CLI bridge. It composes multi-owner captures, but it does not render React Native content.

Two kinds of state cross the boundaries

Structural changes are relatively rare and irregular. A React commit sends changed nodes and child lists from tenant to shell; the shell updates its persistent mirror, then forwards the exact app-surface update over a direct port to the compositor. There is no tenant-to-compositor shortcut.

Presentation values are small and hot. Scroll offsets, native animations, and worklet-driven values are registered once and then published through fixed shared-memory slots, which the compositor reads on its own frame clock with no copying and no wakeups. Layout travels the same way: the tenant’s Yoga pass publishes geometry into a shared table, and the shell and compositor adopt it onto their mirrors instead of computing their own.

One owner, no fallbacks

Every capability in the engine falls out of a single rule: each concern has exactly one owner, and nothing has a fallback. The tenant owns the app tree, the shell owns native state, the compositor owns app pixels. There is no second Yoga pass, no backup renderer, no “if the worker is slow, draw it on the page instead.” It sounds strict, and the strictness is the point.

The tenant runs the authoritative Yoga pass on the same thread as the app’s React code, matching where React Native computes layout during commit. It publishes geometry into a shared table, and the mirrors adopt those numbers rather than computing their own; a mirror lays out a surface itself only for the first frames after it appears, before the tenant’s geometry has landed, so two layout engines can never disagree about where anything is. The hot presentation values, scroll offsets, native animation outputs, and worklet values, live in fixed shared-memory slots that the compositor reads on its own frame clock with zero copies and zero wakeups, so a swipe never waits on a message to move.

Because each owner keeps its own clock, the engine degrades instead of freezing. If the tenant stalls, the shell keeps native interaction alive against its last mirror of the tree, so taps and scrolls still respond. If the shell is descheduled, the compositor keeps scroll and on-screen animation running, and only genuinely new content has to wait. This is how a real phone behaves: blocking the JavaScript thread never stops native scroll, and it does not stop it here either.

The payoff is accuracy. A pile of fairly ordinary techniques held together by strict ownership means there is exactly one place a given behavior can come from, which means there is exactly one place it can be wrong. When a conformance run flags a one-pixel drift or a late gesture callback, we already know which owner to open.

What has to match

Running the app’s actual code means keeping its assumptions true, and apps assume a lot.

At the base are the familiar React Native components: View, Text, Image, ScrollView, TextInput, lists, transforms, gradients, z-order, clipping, and accessibility. Layout has to match Yoga. Text has to wrap and baseline-align with the right font metrics. A responder has to win and lose in the same order. Momentum has to decay like native scroll. The keyboard has to change insets and translucency at the right time.

Then there is the ecosystem. Modern libraries probe RN$Bridgeless, nativeFabricUIManager, and __turboModuleProxy to choose an architecture. SootSim consistently claims Fabric, TurboModules, and Bridgeless, then implements the host-instance shape those libraries actually use: internal-instance links, numeric tags, view configs, measurement, and setNativeProps. When Reanimated or gesture-handler walks from React internals to what it believes is a native view, it lands on a real engine node, and its measure and setNativeProps calls hit the same APIs everything else uses.

The rule at the package boundary:

Use upstream JavaScript. Implement only the native side.

If a package is pure JavaScript, its real source runs here. If @gorhom/bottom-sheet renders incorrectly, the fix belongs in SootSim’s View, gesture, or Reanimated behavior, never in a private SootSim version of the library. Packages with native code get seams for their native modules and views. Native-only packages stay native-only; the browser runtime does not invent web support for them.

Native-looking surfaces also stay outside the guest React tree. Keyboards, alerts, sheets, pickers, the dev menu, and iOS chrome are shell-owned UI. We also had to build a few special cases: Camera, maps, WebGPU, and other external producers transfer ImageBitmaps into retained textures. Other things had to be specifically hidden from the React Native worker, like Blob, URL, and text-codec implementations. We also mirror accessible nodes into a semantic DOM overlay so screen readers and browser automation get real buttons, inputs, and scroll regions, which gives apps decent agentic access out of the box even without our CLI.

There are smaller techniques everywhere: demand-gated frames, dirty-tracked layout, shaping caches, pooled paint objects, subtree bounds for hit-test culling, boot fonts fetched once and shared between workers, module prewarm on press-in. A 120Hz screen gives the engine about 8ms a frame, so none of this is optional.

How we check ourselves

A clean-room implementation can look convincing and still be subtly wrong, so SootSim treats a real iPhone as the specification.

The conformance suite runs the same Detox spec twice, once against real React Native on the iOS simulator and once against SootSim’s implementation of the Detox driver protocol: same IDs, same taps and typing, same screenshots at the same checkpoints, and any disagreement is a SootSim bug, even when the native behavior itself looks like one.

Roughly 48 core cases cover React Native behavior such as layout, typography, responder order, keyboard state, scroll physics, images, transforms, lists, and accessibility. Another 69 exercise libraries including Reanimated, gesture-handler, screens, bottom sheets, and Expo modules. One case often packs several behaviors that have to agree at the same checkpoint.

Each fixture also reports its internal state visibly. Event order, focus, measured geometry, scroll offset, and native-module results appear as labels in the scene. APIs that are normally invisible are made to drive pixels: a worklet changes a width, a gesture changes a transform, an async native result changes rendered text. A missing callback then fails both the semantic assertion and the screenshot.

Three-panel conformance proof. React Native on iOS is on the left, SootSim is in the middle, and the red pixel-difference mask is on the right.

Keyboard translucency over a color grid. Left: React Native on iOS. Middle: SootSim. Right: the generated red difference mask on white. The reviewed content region differs by 0.885%.

The proof pipeline separates content from device chrome instead of blending everything into one flattering score. The headline comparison uses the content band, excludes the status bar and home indicator, and zeroes the device-corner zones. Cases can name smaller element crops; when they do, the worst declared crop becomes the headline. The same suite runs on two iPhone profiles so layout cannot quietly overfit one viewport or corner radius.

The proofs catch regressions, and they keep development pointed at the right layer. When a pure-JS library fails, we compare its upstream source and native behavior, then fix the engine or native seam. The assertion changes only when a fresh iOS run proves the assertion was wrong.

How it got this shape

The current architecture is easier to understand in reverse because almost every boundary exists where an earlier version broke.

First, put everything on one thread

The first engine ran the whole pipeline on the page thread: guest bundle, React, Yoga, and Skia in one context, painting a visible canvas directly. This is the obvious way to build it, and it is genuinely good enough for a demo. One context, no serialization, and console.log shows you everything.

It has a ceiling you hit the moment a real app shows up. The guest app’s JavaScript and the renderer share a thread, so a busy app janks its own pixels, and a wedged app, an infinite loop or a runaway effect, freezes everything around it, including SootSim’s own chrome. A phone does not fail this way: you can peg the JavaScript thread and native scroll and animation keep moving.

Moving the whole runtime into one worker stopped a wedged app from freezing the page, but inside that worker the app and paint still shared a thread, so the deeper problem just moved. So the engine split again, into a tenant worker that owns React and Yoga and a shell worker that owns native interaction and paint. A native animation is described once (curve, duration, start, end) and then stepped by the shell with no per-frame messages, so a wedged guest can no longer take the screen down with it.

The first wire protocol between the two made the split technically correct and practically absurd. It cloned the entire node tree on every dirty frame, 60 to 88 times a second, and structured clone lost identity between the hops so each frame copied everything twice. On a real feed, changing three nodes in an 842-node tree meant copying a 90KB snapshot.

Changed-node deltas cut that same update to 457 bytes. The shell applies them against stable IDs, but the first materializer rebuilt its mirror objects on every delta anyway, which quietly erased every identity-keyed cache: Yoga dirtiness, shaped text, recorded pictures, gradient shaders. Reconciling in place instead took shell frame time from 5.63ms to 2.4ms. A cache never tells you when you have stopped hitting it.

The worker boundary added one more failure mode. A guest render loop once posted tens of thousands of messages a second until Chrome killed the tab with “Aw, Snap.” The fix is send-side backpressure: normal traffic passes straight through, and above a safety rate messages batch on a microtask with every event and its order preserved. Dropping gesture events would have “fixed” the crash by corrupting the app.

Then, teach the canvas to remember

So why does a canvas need help remembering anything? The DOM keeps a retained scene you mutate; a canvas keeps nothing, so anything the engine wants from the last frame it has to hold onto itself.

The first compositor recorded each subtree’s draw commands into a Skia SkPicture and replayed it, on the sensible theory that replaying is cheaper than rebuilding. A home-screen swipe promptly got twice as slow, and the counters said why:

records: 1525 replays: 35 invalidations: 1524

A tiny clock animation lived inside one large flat recording. Every tick changed a child transform and re-recorded the whole parent picture: a cache with a 99.9% miss rate. The boundary was in the wrong place, and an SkPicture bakes its child transforms in at record time, so there is no way to replay one with the child moved.

The replacement models what Fabric and Flutter do: a retained layer tree. Static content is recorded into picture chunks, nested layers split those recordings at their own boundaries, and each layer’s transform, opacity, and clip are applied live when the chunks are composited. Now a moving child re-records only its own small layer while the ancestor’s chunks replay untouched. Damage tracking limits redraws to the regions that actually changed, and a scroll-only frame shifts the previous pixels and paints just the newly revealed strip.

One tier above that is raster promotion: a layer that stays stable for a few frames becomes a GPU texture, so scrolling it costs a blit instead of replaying commands. This tier shipped and then did nothing for months, never promoting a single layer on the home feed, and there were no counters to say why. When we finally added per-gate rejection counters, the cause was two-sided: one screen-tall virtualized slab owned the retained boundary and was far too big to fit the texture budget, while the feed rows that would have fit were blocked because that oversized wrapper held retention above them. The fix was an area gate, where a boundary past about 400k layout px² yields retention to its children. Feed cards became their own boundaries, promotion fired, and feed scroll dropped to about 4ms a frame with the jank gone.

Finally, give app pixels their own clock

After the retained compositor work, normal feed paint in the shell was already around 1-2ms, yet under CPU contention frames could arrive more than 200ms apart. Drawing was cheap; the expensive part was frame delivery, and it was easy to misdiagnose: one profiler sampled the tenant worker, which had not painted anything since the split, and reported a visibly janky scroll at 2069.9 fps, a precise answer to the wrong question.

The current compositor worker is the structural fix. The shell still owns native state and shell pixels, but app canvases, CanvasKit, retained layers, and app presentation move onto another worker with its own requestAnimationFrame. In the worker probe, the compositor held about 8.4ms per frame, roughly 119fps, through 491-501ms host-thread freezes, which is exactly what the split was for. Paint remains demand-gated when nothing is dirty.

A delayed shell still delays new tree content. What the worker guarantees is that pixels and motion already on screen keep a clock nobody else owns.

The compositor split also forced the remaining ownership to become explicit. The shell keeps CPU mirrors for hit testing and native state, but paints no app pixels. Switcher cards and keyboard or alert blur request throttled compositor snapshots. Full capture asks every visible owner for its bitmap and fails rather than silently emitting a partial frame.

As of today

SootSim is broad enough to run production bundles, but the work is not finished. Android needs its own parity program as strong as iOS. Fabric’s synchronous semantics are met, with some exceptions. And conformance is still expanding toward 2k packages, and eventually more.

We are excited by what SootSim unlocks: freedom to develop anywhere, share previews in minutes with just a link, and integrate extremely fast checks into CI, along with all the other interesting paths one can take now that native simulators are no longer tied to incredibly difficult setup processes locked behind non-web platforms.

Ready to build?

Run your React Native app in the browser. No simulators, no native toolchain, no waiting.

curl -fsSL https://sootsim.com/install.sh | sh