SootSim Deep Dive

Run real React Native in a browser tab. No Mac, no Xcode, near-pixel-identical to a device.

SootSim runs React Native apps in a browser. It takes the same Metro bundle that would ship to an iPhone, runs the app code as written, and paints every pixel itself with Skia.

It is not a webview and it is not react-native-web; the app’s real Metro bundle runs against a clean-room implementation of everything native: Yoga layout, the responder and gesture system, native animations, scroll physics, UI, Fabric host instances, and all the surprising things upstream React Native does in practice.

We measure conformance with a large suite of pixel comparing tests, and output difference is nearing a percent or less across the top 1,000 native libraries. This is our goal: to make SootSim a reliable and highly accurate simulator of real React Native apps.

Why? Because a simulator is painful. SootSim boots in seconds, needs no Xcode and no Mac, and runs in a few hundred Mb of memory. It runs anywhere: iOS Safari, a laptop, a Linux CI box. And it has incredible new abilities like a CLI that’s orders of magnitude faster than any native one, consistent across iOS and Android, and deeply integrated not only into React and React Native, but also downstream libraries like react-native-screens, reanimated, react-native-gesture-handler, and more.

Running SootSim never pays for a native build, a fact that alone is worth up to 30 minutes on every CI run. Even discounting the build time, SootSim is still faster than any other 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

The rendering pipeline is short enough to fit on one line:

React → our reconciler → SootNode tree → Yoga → CanvasKit/Skia → canvas

React is renderer-agnostic. React DOM supplies a host config that creates DOM nodes; React Native creates native views; SootSim creates plain JavaScript objects. Each SootSimNode carries resolved React Native style, a Yoga node, children, layout, accessibility metadata, and the host-instance fields expected by Fabric-era libraries.

Yoga is the same layout engine React Native uses, compiled to WebAssembly. CanvasKit is Skia compiled to WebAssembly. Text is shaped with real font metrics, fed back into Yoga for measurement, and finally painted by Skia. Nothing visible in the app surface is an HTML element. We lean hard on both projects, and on React staying renderer-agnostic enough to hand us a host config in the first place.

React Native’s thread model is observable behavior too, so SootSim spreads the runtime across 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 runs the only Yoga pass and publishes geometry into a shared table, so a second layout engine can never drift.

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.” That sounds austere, but it is what makes the interesting parts work.

The tenant runs the only Yoga pass and publishes geometry into a shared table, 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, giving some better out of the box agentic abilities 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. Caches do not announce that you have stopped using them.

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 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 work continues. Android needs its own parity program as strong as iOS. Fabric’s synchronous semantics are met, but with some exceptions. And conformance still is being expanded to cover 2k, and eventually more native packages.

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