The Runtime
On a phone, React Native splits your app across two threads. The JS thread runs your bundle: React renders, your event handlers and your state updates. The UI thread owns what the user sees and touches: scrolling, touch recognition, native-driver animations and Reanimated worklets. The split is why a busy JS thread makes your app slow to respond but does not freeze a scroll in progress.
Peach keeps that split. Your app runs across separate workers in the browser, and each one owns the same work its counterpart owns on a device. This page covers what that means when you write and debug an app. For the full engine design, read How Peach works.
Where your code runs
| on a device | in Peach | owns |
|---|---|---|
| JS thread | tenant worker | your bundle, React, layout, event handlers, TextInput, accessibility, network and storage |
| UI thread | shell worker | scrolling, gesture recognition, native-driver animations, Reanimated worklets, system UI such as the status bar, keyboard and alerts |
| render server | compositor worker | painting every visible pixel with Skia on the GPU, at its own frame rate |
The page itself presents the canvases the compositor paints and routes your pointer input. None of these workers waits on another to draw a frame, so a stall in one does not stop the others.
Your bundle runs on the browser’s own JavaScript engine inside the tenant
worker. Layout is computed with Yoga in that same worker, as React Native
computes it for Fabric, and the results are shared with the other workers, so
a worklet’s measure() reads the same numbers your JS code sees.
What keeps moving when your JavaScript is blocked
A long synchronous loop, a heavy render or a debugger pause stops the tenant worker. On a phone that freezes your JS thread, and Peach behaves the same way:
Keeps moving
- +Scrolling, including momentum and bounce
- +
Animatedanimations started withuseNativeDriver: true - +Reanimated animations and
useAnimatedStyleupdates that run on the UI runtime, including Reanimated CSS animations of opacity and transforms - +Gesture Handler recognition and gesture worklets
- +System UI: the status bar clock, the keyboard, a spinning
ActivityIndicator, the home screen and app switcher
Waits for your JavaScript
- +
onPressand every other JS event handler - +
setState, re-renders and layout changes - +
Animatedanimations withuseNativeDriver: false, which compute every frame on the JS thread - +
runOnJScallbacks from worklets and gestures
When your JavaScript finishes, the queued work runs in order, as it does on device. If an interaction feels stuck in Peach, check the same thing you would check on a phone: whether that animation or gesture runs on the UI thread or waits on JS.
useNativeDriver
useNativeDriver: true moves an animation to the UI thread. In Peach your
Animated.timing, Animated.spring or Animated.decay is described once, sent
to the shell worker and stepped there on its own clock. The compositor paints
each frame from the values the shell writes. The tenant worker does no work per
frame, which is why a native-driver animation keeps running while your
JavaScript is blocked.
Two React Native rules carry over unchanged:
- +A timing curve is sampled into frames up front, as React Native does before handing a native animation to the platform, so a custom
easingfunction runs on the native driver too. - +The native driver animates only non-layout properties: opacity, transforms and colors. Animating
width,height,topor other layout props needsuseNativeDriver: false, as it does on device.
To give an Animated.spring the exact feel of an iOS spring, convert SwiftUI
or UIKit spring parameters with spring() from
peach-kit/animation.
Reanimated and worklets
Reanimated’s UI runtime is the shell worker. Worklets you run on the UI thread,
the mappers behind useAnimatedStyle and useDerivedValue, and Reanimated
event handlers such as useAnimatedScrollHandler all execute there, next to
the scroll and gesture state they read. That matches where they run on device.
- +
runOnUIqueues work on the shell, as on device. - +
runOnUISyncandexecuteOnUIRuntimeSyncblock your JS thread until the shell returns its result, so you get the UI runtime’s real value back synchronously. - +
runOnJShands work back to your JS thread.
Shared values cross between the two runtimes the way they do on device: a write from the UI runtime is visible to your JS code, and a write from JS reaches the worklets that read it.
Gestures
React Native Gesture Handler recognizes gestures on the shell worker, against a
mirror of your view tree, from the same pointer stream the system gestures use.
Worklet callbacks on a gesture run on the UI runtime immediately. Callbacks
that run on JS, either with runOnJS(true) or through runOnJS, are delivered
to your tenant worker in order. Native interactions such as swipe-back, sheet
drags and scroll views negotiate with your gesture handlers as UIKit and Android
do: the recognizer that activated first keeps the touch.
Rendering
The compositor paints with Skia (CanvasKit) on the GPU, through WebGPU where the browser supports it and WebGL otherwise. Each app, the home screen and the system overlay paint to their own surfaces, and the compositor repaints only the surfaces that changed.
Related
- +How Peach works: the worker model, the render pipeline and why stalls never freeze the UI
- +Library compatibility: how much of Reanimated, Gesture Handler and other libraries Peach implements
- +
peach perf: measure frame timing in your app