Skip to content

Commit 2cb95f0

Browse files
fix(example): add web script and fix benchmark crash on web
- Add `web` script to example package.json - Add .web stub for frame-metrics module (no-op on web) - Hide benchmark demo from example index on web
1 parent 9f324c7 commit 2cb95f0

7 files changed

Lines changed: 6606 additions & 5 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# The Real Cost of React Native Animations: Benchmarking Every Approach
2+
3+
React Native has several ways to animate views. They all promise smooth 60fps. But "60fps" doesn't tell you how much work your app is doing per frame — or how much headroom you have left for the rest of your UI.
4+
5+
We built a benchmark that measures the actual per-frame overhead of four animation approaches, running the same animation on the same views, across both platforms. The results reveal significant architectural differences that matter in practice.
6+
7+
## The Approaches
8+
9+
**React Native Animated** (`Animated.timing` with `useNativeDriver: true`) — The built-in API. When the native driver is enabled, the animation loop runs entirely in native code. A `CADisplayLink` (iOS) or `Choreographer` (Android) callback advances the animation clock, computes interpolated values, and applies them directly to native views. No JS per frame, no bridge crossing. The limitation: only non-layout properties (`opacity`, `transform`) are supported, and the animation graph must be declared upfront.
10+
11+
**Reanimated Shared Values** (`useAnimatedStyle` + `withTiming`) — Reanimated's worklet system compiles JS functions into closures that execute on a separate JS runtime synchronized with the UI thread. Each frame, the worklet runtime evaluates the animation function, updates the shared value, runs `useAnimatedStyle`, and applies the resulting style to the view. On Fabric (new architecture), this goes through the React Native shadow tree via a `propsRegistry` that enqueues synchronous Fabric commits. This shadow tree path is key — it enables layout animations but adds per-frame overhead.
12+
13+
**Reanimated CSS** (`createCSSAnimatedComponent` + `css.keyframes()`) — Introduced in Reanimated 4, this API takes a declarative approach inspired by CSS animations. Keyframe definitions are handed off to Reanimated's native C++ engine, which handles per-frame interpolation without executing worklets. The result is applied to views similarly to shared values, but the computation avoids the per-frame JS evaluation overhead.
14+
15+
Both Reanimated approaches also offer experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) that can skip the Fabric commit when only non-layout props are updated (e.g. `transform`, `opacity`), applying them directly to native views instead. This significantly reduces overhead for simple animations. We benchmark both configurations.
16+
17+
**react-native-ease** (`EaseView`) — A thin wrapper around platform animation APIs. On iOS, it uses `CAAnimation` (`CABasicAnimation`/`CASpringAnimation`) which runs on Core Animation's **render server** — a separate OS process that composites animations directly to the display. The app process is not involved per frame at all. On Android, it uses `ObjectAnimator`/`SpringAnimation` which run on the UI thread but bypass the React Native view update pipeline entirely.
18+
19+
## How We Measured
20+
21+
We built a frame-metrics module as an Expo local module in the [react-native-ease example app](https://github.com/AppAndFlow/react-native-ease). Each approach runs the same animation — a continuous translateX loop (0→100px, linear, 2s, reverse/repeat) — on a configurable number of simultaneously animating views (10 to 500).
22+
23+
**Android** uses the `Window.OnFrameMetricsAvailableListener` API, which reports per-frame `ANIMATION_DURATION`, `LAYOUT_MEASURE_DURATION`, and `DRAW_DURATION`. We sum these three values per frame to get the total UI thread work, then compute avg, P95, and P99 percentiles from the per-frame sums.
24+
25+
**iOS** has no public API equivalent to Android's FrameMetrics. We measured per-frame work by **swizzling `CADisplayLink`'s factory method** to wrap every callback with timing. When any animation framework creates a `CADisplayLink`, our proxy intercepts the callback, measures its wall-clock duration, and aggregates all callback durations per frame using the display link's timestamp.
26+
27+
This captures the actual work each framework does in its per-frame callbacks. For Ease on iOS, this shows ~0ms because `CAAnimation` doesn't use display link callbacks at all — it runs on the render server.
28+
29+
## The Results
30+
31+
All results are from release builds. Reanimated is tested both with default settings and with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) enabled (marked "FF"), which bypass the React Native shadow tree for UI prop updates.
32+
33+
### Android — UI Thread Time per Frame (avg ms)
34+
35+
| Views | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated |
36+
|-------|------|---------------|---------------------|----------------|----------------------|-------------|
37+
| 10 | 0.21 | 1.15 | 0.75 | 0.99 | 0.45 | 0.36 |
38+
| 100 | 0.36 | 2.71 | 1.81 | 2.19 | 1.01 | 0.71 |
39+
| 500 | 0.60 | 8.31 | 5.37 | 5.50 | 2.37 | 1.60 |
40+
41+
### iOS — Display Link Callback Time per Frame (avg ms)
42+
43+
| Views | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated |
44+
|-------|------|---------------|---------------------|----------------|----------------------|-------------|
45+
| 10 | 0.01 | 1.33 | 1.08 | 1.06 | 0.63 | 0.83 |
46+
| 100 | 0.01 | 3.72 | 3.33 | 2.71 | 2.48 | 3.32 |
47+
| 500 | 0.01 | 6.84 | 6.54 | 4.16 | 3.70 | 4.91 |
48+
49+
### What the Numbers Mean
50+
51+
At 500 views on Android, Reanimated SV consumes 8.31ms of UI thread time per frame. At 60fps the frame budget is 16.67ms — that's half the budget gone before any other UI work happens. On devices with 120Hz displays (iPhone Pro, many modern Android phones), the budget shrinks to 8.33ms, meaning Reanimated SV alone would saturate the entire frame. Ease uses 0.60ms for the same animation — 14x less.
52+
53+
On iOS, the difference is even more dramatic. Ease registers 0.01ms because `CAAnimation` runs on the Core Animation render server, a separate OS process that composites directly to the display. The app's main thread is entirely uninvolved. No other animation approach available in React Native achieves this level of offloading.
54+
55+
## Why the Differences Exist
56+
57+
The performance gaps come down to what each approach does per frame and which thread does the work.
58+
59+
### The Shadow Tree Tax
60+
61+
On Fabric (new architecture), Reanimated shared values apply animated props through React Native's shadow tree. Each frame, after computing the new value in the worklet runtime, Reanimated enqueues a synchronous Fabric commit to update the shadow node's props. This triggers the Fabric mounting layer, which diffs the shadow tree and applies changes to native views.
62+
63+
This is architecturally necessary for Reanimated's flexibility — it allows animating any prop, including layout-affecting ones like `width` and `height` — but it adds overhead even for simple transform/opacity animations that don't need layout recalculation.
64+
65+
### Feature Flags: Bypassing the Shadow Tree
66+
67+
Reanimated's experimental feature flags (`SYNCHRONOUSLY_UPDATE_UI_PROPS` for both platforms) bypass the shadow tree for UI prop updates, applying them directly to native views instead. The impact is significant:
68+
69+
- **Android at 500 views:** Reanimated SV drops from 8.31ms → 5.37ms (-35%). Reanimated CSS drops from 5.50ms → 2.37ms (-57%).
70+
- **iOS at 500 views:** Reanimated SV drops from 6.84ms → 6.54ms (-4%). Reanimated CSS drops from 4.16ms → 3.70ms (-11%).
71+
72+
The Android improvement is much larger because the shadow tree overhead is more expensive there. On iOS, the worklet evaluation itself is a bigger portion of the cost.
73+
74+
### Why RN Animated Is Faster Than You'd Expect
75+
76+
RN Animated with native driver scores surprisingly well — 1.60ms at 500 views on Android, roughly 3x better than Reanimated SV. This is because the native animated driver applies prop updates directly to native views without going through the shadow tree. It traverses its own lightweight animated node graph instead. The trade-off is less flexibility — you can't run arbitrary JS logic per frame or animate layout properties.
77+
78+
### Why Ease Is Nearly Free
79+
80+
Ease takes the most direct path possible: it calls platform animation APIs (`CAAnimation`, `ObjectAnimator`) on the native view itself. There is no per-frame callback in the app process on iOS — Core Animation handles everything in a separate render server process. On Android, `ObjectAnimator` does run on the UI thread, but it updates a single view property directly without any graph traversal, shadow tree commits, or JS evaluation.
81+
82+
The 0.60ms at 500 views on Android is the cost of 500 `ObjectAnimator` instances updating `translationX` on their respective views. It scales linearly but starts from a very low base.
83+
84+
## A Note on Debug vs Release Performance
85+
86+
These benchmarks use release builds for a reason. Debug builds can be **dramatically** slower for Reanimated in particular, due to multiple layers of debug-only overhead:
87+
88+
In debug builds, both Reanimated and React Native add significant per-frame overhead: C++ code is compiled without optimizations, Hermes parses raw JavaScript instead of precompiled AOT bytecode, and Fabric's shadow tree runs additional validation checks on every commit. These costs compound — a Reanimated animation that takes 3ms per frame in release can easily take 10ms+ in debug. Always benchmark with release builds.
89+
90+
## Animation Best Practices
91+
92+
Regardless of which library you use, what you animate matters as much as how you animate it.
93+
94+
**Prefer non-layout properties.** Animating `transform` (translate, scale, rotate) and `opacity` is cheap on every platform because these are compositor properties — they can be applied without recalculating layout or redrawing the view. Animating `width`, `height`, `margin`, or `padding` triggers a layout pass every frame, which is significantly more expensive.
95+
96+
**Ease enforces this by design.** The `EaseView` component only supports properties that are efficient to animate: `opacity`, `translateX/Y`, `scale`, `rotate`, `borderRadius`, and `backgroundColor`. There's no way to accidentally animate a layout property and tank your frame rate. Other libraries give you the flexibility to animate anything — including expensive layout props — which is powerful but requires discipline to avoid performance pitfalls.
97+
98+
## What This Means in Practice
99+
100+
For most apps, the differences at 10-50 views are negligible — all approaches are well under the frame budget. The choice between libraries should be based on API needs, not performance.
101+
102+
But when you're building:
103+
- **Screens with many simultaneous animations** (lists with animated items, particle effects, complex transitions)
104+
- **Apps targeting low-end Android devices** where the frame budget is tighter
105+
- **Animations that coexist with expensive UI work** (maps, video, heavy lists)
106+
107+
...the per-frame overhead matters. Using 8ms of your 16.67ms budget on animation leaves little room for everything else.
108+
109+
### Choosing the Right Tool
110+
111+
**Use react-native-ease when:** You need simple property animations (fade, slide, scale, rotate) and want the absolute minimum overhead. Works great for enter/exit animations, state-driven transitions, and any animation that can be expressed as "animate from A to B."
112+
113+
**Use Reanimated when:** You need gesture-driven animations, layout animations, complex interpolations, or shared element transitions. The overhead is justified by the flexibility. Consider enabling feature flags for pure UI animations that don't need layout recalculation.
114+
115+
**Use RN Animated when:** You're already using it and the native driver covers your needs. It's a solid middle ground — no per-frame JS, no shadow tree involvement, good enough for most cases.
116+
117+
## Reproduce It Yourself
118+
119+
The benchmark is included in the [react-native-ease example app](https://github.com/AppAndFlow/react-native-ease). Clone the repo, build the example, navigate to Advanced → Benchmark, and run it on your own devices. The numbers above are from release builds on an M4 MacBook Pro (emulator/simulator) and iPhone 16 Pro — your results will vary by device.
120+
121+
The [frame-metrics module source](https://github.com/AppAndFlow/react-native-ease/tree/main/example/modules/frame-metrics) is available if you want to integrate similar measurements into your own app.
122+
123+
---
124+
125+
*[react-native-ease](https://github.com/AppAndFlow/react-native-ease) is built by [App & Flow](https://appandflow.com), a Montreal-based React Native engineering studio recommended by Expo.*
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# PR #4 Review Fixes: Per-Property Transition Simplification
2+
3+
## Problem
4+
5+
PR #4 review feedback identifies several areas of unnecessary complexity in the per-property transition implementation:
6+
7+
1. JS duplicates native types and over-expands configs
8+
2. Spring defaults for transforms add special-case logic that isn't needed
9+
3. Android has redundant default-filling that duplicates JS logic
10+
4. Web doesn't support per-property transitions
11+
5. README needs updating
12+
13+
## Changes
14+
15+
### 1. JS Transition Resolution (src/EaseView.tsx)
16+
17+
- Remove `NativeTransitionConfig` and `NativeTransitions` types — import from codegen spec
18+
- Remove `SPRING_DEFAULT_CONFIG` — timing 300ms easeInOut is the default for all properties
19+
- Simplify `resolveTransitions()`:
20+
- Single transition → `{ defaultConfig: resolveSingleConfig(config) }`
21+
- No transition → `{ defaultConfig: DEFAULT_CONFIG }`
22+
- TransitionMap → `{ defaultConfig: resolve(map.default ?? DEFAULT), ...only specified categories }`
23+
- Remove spring default injection for transforms (lines 198-201)
24+
25+
### 2. Codegen Spec (src/EaseViewNativeComponent.ts)
26+
27+
- Keep `type` as string (codegen doesn't support enums in struct fields)
28+
- No changes needed — struct shape is already correct
29+
30+
### 3. Android Defaults Cleanup (EaseView.kt)
31+
32+
- Remove redundant default-filling in `setTransitionsFromMap()` — trust that JS always sends complete configs
33+
- Remove hardcoded fallback `TransitionConfig(...)` in `getTransitionConfig()``defaultConfig` is always present from JS
34+
35+
### 4. Web Per-Property Transitions (src/EaseView.web.tsx)
36+
37+
- Replace `resolveSingleTransition()` with proper per-property CSS transition resolution
38+
- Build per-property CSS transition strings: `opacity 200ms ease, transform 500ms cubic-bezier(...), ...`
39+
- Each category gets its own duration/easing from resolved config
40+
- `type: 'none'``0ms` duration for that property
41+
- Spring → approximate with existing duration heuristic
42+
43+
### 5. Tests (src/__tests__/EaseView.test.tsx)
44+
45+
- Remove/update tests asserting spring defaults for transforms
46+
- Verify simplified resolution logic
47+
48+
### 6. README
49+
50+
- Update per-property transition docs
51+
- Remove mentions of spring defaults for transforms
52+
53+
## Not Changing
54+
55+
- iOS native code — struct format and fallback logic already correct
56+
- Types in `src/types.ts` — already correct
57+
- Overall architecture — category-based keys remain the same

0 commit comments

Comments
 (0)