[ Documentation ]
How Peach works

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

contextownsits counterpart on a phone
host pagethe browser tab: worker lifecycle, the stack of device canvases, pointer and keyboard capture, the desktop windowthe window server and input hardware
tenant workeryour app’s JavaScript bundle, React, Yoga layout, JS callbacks, TextInput state, accessibility, network and storage effectsthe JS thread
shell workernative state: the home screen and app switcher, system chrome, alerts, sheets and keyboards, scroll physics, gesture recognition, native animation drivers, Reanimated UI workletsthe UI thread
compositor workerevery visible pixel, painted with CanvasKit (Skia compiled to WebAssembly) on its own GPU surfaces and its own frame clockthe render server and GPU
host page ── input, canvas stack, window │ ├── tenant worker ── your JS, React, Yoga │ │ tree commits and layout │ ▼ ├── shell worker ─── native state, scroll, gestures, native drivers │ │ scene updates and animation drives │ ▼ └── compositor worker ── CanvasKit paint, own frame clock ──► screen

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 onScroll handlers 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.

  1. +
    Reconcile. React commits your component tree into Peach nodes in the tenant worker.
  2. +
    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.
  3. +
    Mirror. The tree crosses to the shell, which keeps a mirror for hit testing, gestures and native state, and forwards what the compositor needs.
  4. +
    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