How Peach works
A phone never runs your whole app on one thread. React Native executes your JavaScript on a JS thread, while the platform’s UI thread owns scrolling, gestures, native animations, and the pixels on screen. When your JavaScript stalls for a moment, a list you are flicking keeps gliding and a screen push keeps sliding, because the work that moves them never waited on JavaScript.
A browser tab gives you one main thread by default. If Peach ran your app, React, layout, and painting all there, any long task would freeze the whole device. So Peach rebuilds the phone’s thread split out of browser workers, and assigns each part of the device to the context that owns it on real hardware.
Four contexts
| context | owns | its counterpart on a phone |
|---|---|---|
| host page | the browser tab: worker lifecycle, the stack of device canvases, pointer and keyboard capture, the desktop window | the window server and input hardware |
| tenant worker | your app’s JavaScript bundle, React, Yoga layout, JS callbacks, TextInput state, accessibility, network and storage effects | the JS thread |
| shell worker | native state: the home screen and app switcher, system chrome, alerts, sheets and keyboards, scroll physics, gesture recognition, native animation drivers, Reanimated UI worklets | the UI thread |
| compositor worker | every visible pixel, painted with CanvasKit (Skia compiled to WebAssembly) on its own GPU surfaces and its own frame clock | the render server and GPU |
The tenant worker talks to the shell worker over a direct channel, so your
app’s tree commits never queue behind the host page. The shell talks to the
compositor directly too. The host page mostly routes input and presents the
canvases the compositor draws into: each canvas is handed to the compositor
once as an OffscreenCanvas, and from then on only the compositor can draw on
it.
CanvasKit is compiled once per tab and the compiled module is shared with each worker, which instantiates its own copy. When the browser exposes WebGPU in a worker, the compositor paints with Skia’s Graphite backend; otherwise it uses Skia’s WebGL backend. Peach asks the compositor’s own worker which one works, because a page and a worker in the same browser can answer differently.
Why a busy JS thread does not freeze the UI
Because each context owns its own loop, a blocked tenant worker only stops the things that genuinely need your JavaScript:
- +Scrolling runs in the shell. Drag tracking, rubber banding and momentum advance on the shell’s clock, and the compositor samples the momentum curve on its own frame clock. Your
onScrollhandlers catch up when the JS thread frees up, the same way they do on a device. - +Native animations run off the JS thread, as described below.
- +System UI such as the status bar, the home indicator, keyboards, alerts and the app switcher belongs to the shell and keeps working.
- +Painting continues: the compositor keeps presenting the most recently published tree at full frame rate.
Inside the tenant worker, React’s scheduler yields after each one-frame slice of work, so taps and commands from the host get in between render tasks rather than waiting behind a long update. A render that never settles still cannot starve input.
The render pipeline
Your components render through a custom React reconciler into Peach’s own node
tree. There is no DOM inside the device screen: each View, Text and
Image is a node that Peach lays out with Yoga, the same flexbox engine React
Native uses, and paints with Skia.
- +Reconcile. React commits your component tree into Peach nodes in the tenant worker.
- +Lay out. The tenant worker owns the authoritative Yoga layout and publishes each node’s frame into a shared geometry table. The shell and compositor adopt those frames for hit testing and paint without running layout themselves. In a tab without
SharedArrayBuffer, their mirrors run Yoga locally instead. - +Mirror. The tree crosses to the shell, which keeps a mirror for hit testing, gestures and native state, and forwards what the compositor needs.
- +Paint. The compositor walks the tree and draws it with Skia onto the GPU surface for that app.
Text goes through the same path. Peach measures text with the platform’s own font metrics and line breaking rules during layout and paints the same shaped lines, so a label’s measured box and its pixels agree.
Incremental layout
React commits far more often than layout actually changes. A data load that updates a counter, or an effect chain that re-renders a screen with identical styles, marks nodes dirty without moving anything. Peach tracks which nodes gained or lost children, changed a measured shape, or changed a layout-affecting style, and updates only those nodes in the existing Yoga tree. When none of that changed and the viewport is the same size, it skips the layout calculation and readback entirely, since Yoga gives the same answer for the same inputs. This matters most during stack transitions, when screens keep committing while they animate.
Layers and partial repaint
Repainting a whole phone screen sixty times a second for one moving element wastes most of the frame. Peach uses two techniques that Fabric and UIKit apply in native code.
A layer compositor. A subtree can become a layer host. Peach records the host’s children once into Skia pictures, and every later frame replays those pictures under the host’s live transform, opacity and clip. A parent records a nested layer by its slot alone. So when a child animates, only that child’s layer is re-recorded; its ancestors keep replaying what they already recorded. Moving, fading or clipping a layer never re-records anything.
Damage rectangles. Each surface accumulates the regions that changed since the last frame: the old and new bounds of each layer that moved or repainted. At paint time the compositor clips to that region, keeps the pixels outside it, and skips subtrees that fall entirely outside it. When the damage covers most of the surface (more than 80%), a full repaint is cheaper than the clip, so Peach repaints everything.
These cut the cost of each frame. They never change the frame rate: continuous motion, such as the second hand on the home screen clock, presents on every display frame for as long as it is visible.
Fabric self-identification
Modern React Native libraries check which architecture they are running on before they choose a code path. Reanimated, React Native Screens, FlashList, Bottom Sheet and Keyboard Controller each probe for the New Architecture and behave differently without it.
Before any module in your bundle runs, Peach installs the globals that a bridgeless Fabric app sees on a device: the bridgeless flag, the Fabric UI manager, and the TurboModule proxy. Every node Peach creates carries the host instance shape Fabric gives native views, so a library that walks from a component ref to its native node lands on a real Peach node and can measure it, update its props, or attach a worklet to it.
If your native project turns the New Architecture off (newArchEnabled=false
on Android, or RCT_NEW_ARCH_ENABLED=0 in the iOS Podfile), the CLI reads that
setting and Peach presents the legacy renderer instead, with the legacy
renderer’s text measurement.
Native-driver animations across workers
On a device, Animated.timing(value, { useNativeDriver: true }) serializes the
animation and hands it to the native side, which runs it on the UI thread.
That is why a native-driven animation keeps running while JavaScript is
blocked.
Peach does the same thing across workers. When your app starts a native-driven
animation, the tenant worker describes the whole Animated graph (timing,
spring and decay leaves, interpolations, and composites such as loop,
sequence, parallel and delay) and publishes that description once. The
shell binds it to the target views’ mirror nodes and passes it to the
compositor, which steps the animation on its own frame clock with the same
timing, spring and decay math React Native uses, and writes the values into
shared slots. No per-frame message is needed, so a wedged
tenant, or even a busy shell, cannot freeze it. Your start() callback still
resolves in your JavaScript when the animation ends, as it does on a device.
As on a device, useNativeDriver covers opacity and transforms. React Native
Screens transitions, Reanimated UI worklets, and Reanimated CSS keyframe animations
that only touch opacity and transforms follow the same rule: the motion lives with
the native side, and your JS thread only learns how it ended.
Fast and slow scene channels
The shell publishes the device scene, meaning everything about the device beyond your app’s own tree, in two channels:
- +A fast channel for interactive numeric values, such as presentation progress and positions, written into a shared
Float64Array. Readers pick up the latest values when they need them, so a value that changes every frame costs no message per frame. - +A slow channel for structured state, such as the active app, recent apps, focus and transitions, sent only when one of those fields actually changed.
Scroll offsets, layout frames and animated values use the same idea: numbers that change every frame live in shared memory, and messages carry structural change.
Backpressure
A misbehaving app can flood a message channel: a re-render loop over a list of gesture-bearing rows, a runaway animation, or a third-party bundle that thrashes can post tens of thousands of messages per second. Past a few thousand per second the browser’s main thread wedges draining the backlog and the tab can crash.
Peach watches the rate on its high-frequency channels from the tenant worker. Below 600 messages in 100 milliseconds, every message goes out immediately, so ordinary gesture and animation latency is unchanged. Above it, messages are coalesced into one batch per microtask, in order. Nothing is dropped, so the app behaves the same, and the host drains a handful of batches instead of every message.
Where to go next
- +How the runtime runs your app: what the JS thread and UI thread split means for
useNativeDriver, worklets and your own code. - +How we measure conformance: how Peach is compared pixel by pixel against real devices.
- +Compatibility: check how your project’s packages score.