
Optimising Expo React Native Performance
- 76 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with frontend development tasks during AI-assisted development.
About
optimising-expo-react-native-performance is a Claude Code skill in the Frontend Development category.
- optimising-expo-react-native-performance
- Frontend Development
- AI-coding skill
Optimising Expo React Native Performance by the numbers
- 76 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,126 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill optimising-expo-react-native-performanceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 76 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with frontend development tasks during AI-assisted development.
Files
Summary
This skill turns “the app feels slow/janky” into a measured, repeatable, and shippable optimisation program for Expo-managed React Native apps.
Non‑negotiables:
- Optimise against user-visible KPIs (startup/TTI, scroll FPS, navigation responsiveness, memory growth, p95 network latency).
- Profile in production-like builds (release / profile / debugOptimized) — not in dev mode.
- Make one change at a time, re-measure, and keep a rollback path.
When to use
Use when you need to:
- Fix slow startup, “white screen”, or delayed time-to-interactive.
- Fix scroll jank, dropped frames, sluggish taps, or slow transitions.
- Reduce memory growth, crashes under pressure, or image/video bloat.
- Reduce OTA update size, JS bundle size, or Android binary size.
- Add regression prevention: perf budgets + CI gates + production monitoring.
When NOT to use
Don’t use this skill to:
- Prematurely micro-optimise already-smooth screens with no KPI regression.
- Make changes without a reproducible scenario and a baseline.
- “Optimise” by switching libraries blindly (measure first).
Inputs
- Repo (Expo managed or CNG/prebuild), ideally with:
package.jsonapp.json/app.config.*metro.config.js(if present)babel.config.*eas.json(if using EAS)- A concrete report of the problem:
- Device(s), OS versions, and which flow feels slow.
- Steps to reproduce (or a screen name if using Expo Router).
If details are missing, infer as much as possible from the repo and propose a minimal repro script.
Outputs
Deliver both: 1) Perf audit report (see template in assets/templates/perf-audit-report-template.md):
- KPIs + budgets
- Baseline measurements (device + build type)
- Root cause hypothesis + evidence
- Fix plan (ordered by ROI / risk)
- Before/after measurements
2) Code changes (PR-ready) implementing the top fixes, plus:
- Updated perf budgets (if needed)
- CI gate(s) for bundle/update size at minimum
Tooling assumptions
You can use:
- Expo CLI (
npx expo …), EAS CLI (eas …) where available. - React Native DevTools (Performance + Memory panels) for JS-level analysis.
- Native profilers (Android Studio, Xcode Instruments) for CPU/memory/UI tracing.
You should prefer:
- Release/profile builds for measurement.
- Same device class and same scenario script for before/after.
The optimisation workflow (high level)
1) Define KPIs + budgets (3–6 metrics max). Pick what users feel. 2) Create a repeatable scenario (startup, list scroll, key navigation, etc.). 3) Measure baseline in a production-like build. 4) Classify the bottleneck domain:
- Startup/bundle
- JS thread
- UI thread
- Lists/images
- Memory
- Network/background
5) Apply targeted fixes (smallest change, highest ROI first). 6) Re-measure. Keep only changes with KPI wins. 7) Add regression control (budgets + CI gates + monitoring).
Detailed playbook
Phase 0 — Establish reality (no guesswork)
0.1 Identify versions and architecture
- Expo SDK version, React Native version, React version.
- New Architecture status (mandatory in newer SDKs).
- JS engine (Hermes/JSC/V8), OTA updates usage (
expo-updates). - Major perf-sensitive libs: navigation (Expo Router/React Navigation), lists (FlashList), animation (Reanimated), images (
expo-image).
0.2 Choose KPIs (pick 3–6) Suggested defaults:
- Cold start: time-to-first-render and/or time-to-interactive
- Scroll: dropped frames / FPS on the heaviest list
- Navigation: p95 screen transition time for a representative flow
- Memory: steady-state RSS after repeating a navigation loop 5–10×
- Network: p95 API latency on a key endpoint
Record budgets as numbers (not “fast”). See references/00-principles-and-kpis.md.
0.3 Choose build type for measuring
- Prefer store-equivalent Release.
- If you need debuggability, use Android “profileable” builds, iOS Instruments, or Expo’s
debugOptimizedwhere applicable.
Phase 1 — Baseline measurement (release-build discipline)
1.1 Baseline checklist (must pass)
- Dev mode off.
- No remote JS debugging.
- Same device, same OS version, same network conditions.
- Warm vs cold start explicitly noted.
1.2 Capture traces and numbers
- React Native DevTools:
- Performance trace (JS execution + React tracks + network events)
- Heap snapshot (if memory suspected)
- Native tools:
- Android Studio System Trace for jank attribution
- Xcode Instruments (Time Profiler / Allocations / Leaks)
Store raw artefacts (trace files, screenshots) alongside your report.
Phase 2 — Decide the bottleneck domain
Use this decision rubric:
- Startup slow: long splash, white screen, slow first render → startup/bundle.
- Taps lag / transitions slow but scrolling OK → JS thread or navigation.
- Scroll stutters even with little JS work → UI thread or list/render cost.
- Memory climbs over time → leak / image/video pressure.
- Everything waits on API → network/caching.
Phase 3 — Apply high-ROI fixes by domain
A) Startup & bundle
Do in this order: 1) Stop doing work before first paint
- Gate only critical assets (fonts, tiny config) and hide splash ASAP.
2) Confirm Hermes
- Make it explicit in app config if necessary.
- If you use OTA updates, ensure runtime compatibility when engine/bytecode changes.
3) Shrink JS evaluation
- Prefer ESM imports, avoid breaking tree shaking.
- Consider Metro
inlineRequires(validate side effects!).
4) Control OTA payloads
- Configure update asset inclusion/exclusion and verify assets.
5) Android size knobs (measure trade-offs)
- Enable R8 minify + resource shrinking.
- Treat bundle compression as a measured toggle (smaller APK vs slower startup).
See references/02-startup-bundle-ota.md.
B) JS thread stalls (renders + computation)
High ROI: 1) Remove production console.*. 2) Defer heavy work with InteractionManager / requestAnimationFrame. 3) Reduce re-renders:
- Stabilise props, split context, memoise hot rows.
- Consider React Compiler (branch rollout + profiling + easy rollback).
See references/03-rendering-js-ui.md.
C) UI thread / rendering / animations
High ROI: 1) Prefer native-driven transitions (native stack / react-native-screens). 2) Avoid expensive UI operations on animated frames:
- Alpha compositing, heavy shadows, animating image size.
3) Use native-driver animations where possible; for complex gestures prefer Reanimated worklets.
See references/03-rendering-js-ui.md.
D) Lists, images, and media
High ROI: 1) Fix list fundamentals:
- Stable keys, avoid re-render storms, tune render window.
- Add
getItemLayoutwhen item heights are known.
2) If still janky: evaluate FlashList for large/complex feeds. 3) Move image-heavy UIs to expo-image with caching + placeholders. 4) For replay-heavy video: use expo-video caching with a storage policy.
See references/04-lists-images-media.md.
E) Memory leaks / pressure
High ROI: 1) Reproduce with a navigation stress loop. 2) Take JS heap snapshots (before/after) to spot retained graphs. 3) If JS heap stable but RSS grows: switch to native allocation tools.
See references/01-profiling-toolchain.md.
F) Network & background work
High ROI: 1) Prevent refetch storms: cache + dedupe + prefetch. 2) Use platform-appropriate background scheduling (best effort) for sync.
See references/05-network-background.md.
Phase 4 — Regression control (the “next level”)
Minimum viable regression control:
- Budget file committed (bundle size + a few KPI thresholds).
- CI gate that fails on obvious regressions (bundle/update size growth).
- Production monitoring (crash + perf traces) with symbolication.
See references/06-ci-regression.md.
Common pitfalls (things this skill forbids)
- Benchmarking in dev mode and trusting the results.
- Making 5 optimisations at once, then not knowing which mattered.
- “Fixing” a symptom (e.g. bigger splash delay) instead of root cause (slow JS eval).
- Turning on size flags (bundle compression, aggressive shrinking) without measuring startup and runtime.
Fast checklist
- [ ] KPIs chosen (3–6) + budgets written down
- [ ] Baseline measured in production-like build
- [ ] Bottleneck domain identified with evidence
- [ ] One fix at a time + before/after numbers
- [ ] At least one regression gate added (bundle/update size)
- [ ] Monitoring configured (crash + perf)
References
Start here:
references/00-principles-and-kpis.mdreferences/01-profiling-toolchain.mdreferences/02-startup-bundle-ota.mdreferences/03-rendering-js-ui.mdreferences/04-lists-images-media.mdreferences/06-ci-regression.md
External links: see references/resources.md.
Performance audit report (template)
Context
- App name:
- Repo / branch / commit:
- Expo SDK / RN / React:
- New Architecture:
- JS engine (Hermes/JSC/V8):
- Build type measured (Release/Profile/debugOptimized):
- Devices tested:
- Device A (model, OS)
- Device B (model, OS)
Problem statement
What users report:
Reproduction steps: 1) 2) 3)
KPIs and budgets
| KPI | Budget | Baseline | After | Notes |
|---|---|---|---|---|
| Cold start → first render | ||||
| TTI | ||||
| Scroll smoothness (scenario) | ||||
| Navigation p95 (flow) | ||||
| Memory steady-state | ||||
| Network p95 (endpoint) |
Baseline evidence
Attach:
- DevTools traces:
- Android System Trace / Perfetto:
- Instruments traces:
- Screenshots of key profiler views:
Observations:
- JS thread:
- UI thread:
- Memory:
- Network:
Root cause hypothesis
Hypothesis:
Evidence that supports it: -
Evidence against it / risks: -
Fix plan (ordered by ROI)
Fix 1
- Goal:
- Change:
- Risk:
- Rollback:
- How we measure success:
Fix 2
...
Results
- Before/after KPI table updated.
- Notes about variability (cold vs warm, device variance).
Regression control
- Budgets file updated/created:
- CI gates added:
- Monitoring configured:
- Remaining known risks:
Follow-ups
- Deferred optimisations:
- Suggested refactors:
- Long-term improvements:
{
"$schema": "https://example.com/perf-budgets.schema.json",
"notes": "Copy to your repo as perf-budgets.json and tune values per app + device targets.",
"bundle": {
"maxJsBundleBytes": 4000000,
"maxTotalAssetsBytes": 15000000,
"maxOtaUpdateBytes": 5000000
},
"kpis": {
"coldStartToFirstRenderMs": 1500,
"timeToInteractiveMs": 2000,
"navigationP95Ms": 500,
"memorySteadyStateMb": 350
}
}
Principles, KPIs, and perf budgets
The performance model to keep in your head
- Most user-perceived “jank” is a missed frame deadline.
- 60Hz devices: ~16.67ms per frame.
- 90Hz: ~11.11ms per frame.
- 120Hz: ~8.33ms per frame.
- React Native problems usually fall into one of two buckets:
1) JS thread stalls: React renders, JS-driven animations, event handlers, heavy computation. 2) UI thread overload: layout/drawing, view compositing, native transitions, native animations.
A classic symptom split:
- Scroll is smooth but taps/transitions lag → JS thread.
- Taps are instant but scrolling stutters / visual stutter → UI thread.
Non-negotiable measurement rules
1) Don’t trust dev mode perf.
- Dev builds add validation, error overlays, logging, and debug hooks.
2) Use production-like builds for numbers.
- Release builds for final truth.
- Profileable/profile builds when you need tooling.
3) Control the scenario.
- Same device class, OS version, and steps.
- Note cold vs warm start.
- Note network conditions.
4) One change at a time.
- Keep a log of each change and which KPI moved.
KPI menu (pick 3–6)
Choose KPIs that match what users feel, not what feels “technical”.
Startup
- Cold start time: app launch → first meaningful paint.
- Time to interactive (TTI): launch → app responds reliably to taps.
Interaction
- Scroll smoothness: dropped frames or FPS on a representative long list.
- Navigation responsiveness: p95 time for a screen transition.
- Input latency: tap → UI feedback visible.
Memory
- Steady-state memory after repeating a key navigation flow (5–10×).
- Memory growth rate over a fixed usage script.
Network
- p95 API latency for your core endpoint.
- p95 screen data-ready time: navigation → content rendered.
Perf budgets
A budget is a pass/fail number that you can enforce in reviews and CI.
Suggested starter budgets (adjust per app):
- Cold start → first render: < 1.5s on your “mid-tier Android” test device.
- Navigation p95: < 250ms for simple screens, < 500ms for heavy screens.
- Long-list scroll: no sustained jank in a 10s fast scroll; no obvious blanking.
- Memory: no upward drift after 10 loops of the key flow.
- OTA update size: < 2–5MB typical patch (depends on app).
Make budgets explicit
Commit a budgets file (example in assets/templates/perf-budgets.example.json) and update it only when you have a clear reason (new feature, new baseline, new device target).
A minimal “baseline loop” template
1) Define KPIs + budgets. 2) Write a scenario script (bullet steps). 3) Measure baseline (release/profile build). 4) Pick the bottleneck domain. 5) Apply one fix. 6) Re-measure. 7) Keep or revert. 8) Add/adjust regression gates.
Links
See references/resources.md for official docs (React Native perf & profiling, Expo Hermes/tree shaking/EAS).
Profiling toolchain for Expo + React Native
Golden rule
Always profile performance in production-like builds:
- Release builds for final numbers.
- “Profileable” / “debugOptimized” / profiling builds when you need tooling.
Dev mode can massively distort JS thread timing.
What to use for what
React Native DevTools (JS + React)
Use for:
- JS execution timelines and React commit timings.
- Identifying long tasks on the JS thread.
- Heap snapshots and JS memory growth (JS heap only).
Key panels:
- Performance: record a trace, inspect JS execution + React tracks + network events.
- Memory: take heap snapshots and allocation timelines.
- React Profiler: find components with expensive commits.
Expo tip:
- In Expo projects, DevTools can be opened from the Expo CLI terminal (commonly by pressing
j).
Notes:
- DevTools features depend on your runtime/engine (Hermes is the assumed default).
- Some debugging is not available or is limited in true Release builds.
Android: Android Studio Profiler + System Trace
Use for:
- UI jank attribution across threads.
- CPU, memory allocations, leaks, and system tracing.
Practical workflow: 1) Open the android/ project in Android Studio (requires prebuild/CNG if you don’t have native folders). 2) Run as profileable. 3) Use the System Trace / “Capture System Activities” workflow. 4) Look at frame boundaries, main thread work, RenderThread, and JS thread.
Export traces to Perfetto if useful for sharing.
iOS: Xcode Instruments
Use for:
- Time Profiler (CPU hot spots).
- Allocations and Leaks.
- Networking templates.
Workflow: 1) Build and run a production-like build. 2) Record a Time Profiler trace around the problematic interaction. 3) For memory: use Allocations/Leaks while repeating the suspect flow.
Measuring in Release builds (real-world constraints)
- Many “debug conveniences” are missing in Release builds.
- Native profilers still work in Release builds.
- Expo offers intermediate build modes (for example “debugOptimized” in some workflows) that can be closer to production performance while retaining some debugging; use these when you need a middle ground.
- If you must capture JS/Hermes profiles in Release builds, consider an approach like:
- Recording traces with platform profilers.
- Using a release profiling helper library.
Optional: react-native-release-profiler
If you need JS performance tooling closer to release builds, evaluate react-native-release-profiler (Margelo).
Caveats:
- It involves native integration (check installation steps and compatibility).
- In Expo managed projects you may need prebuild/CNG and the additional CLI dependency it mentions.
Memory debugging workflow (practical)
1) Reproduce with a stress loop:
- Navigate A → B → A, repeat 10×.
- Or scroll an image grid 10×.
2) Watch if memory returns to baseline. 3) If JS heap grows:
- Use RN DevTools Memory panel, take heap snapshots before/after.
4) If JS heap is stable but RSS keeps rising:
- Use Instruments / Android Studio memory tooling to find native allocations.
What to save in your perf report
Always attach:
- Build type (Release/Profile/debugOptimized) and exact commit hash.
- Device model + OS version.
- Raw trace artefacts where possible.
- A before/after KPI table.
Links
See references/resources.md for the official React Native profiling guide, DevTools docs, and Expo debugging docs.
Startup, bundle size, and OTA update performance
Startup anatomy (what usually dominates)
Cold start is typically a combination of: 1) Native bootstrap 2) JS bundle I/O + parse/execute 3) Synchronous module initialisation 4) First screen render (and any blocking async work you made “sync” via gating)
Your goal is not “zero work”, it’s:
- minimum work before first paint, and
- minimum JS evaluation before the first interactive screen.
Measure startup correctly
- Separate cold start vs warm start.
- Measure in Release (or closest available) builds.
- Use a simple scenario: launch → first meaningful screen.
If you can’t get a release build quickly, an intermediate step is to run production-mode bundling in a dev client (still not identical to Release):
npx expo start --no-dev --minifyHigh-ROI fixes
1) Splash screen gating (do less, not more)
Pattern:
- Keep splash visible while loading only truly critical resources (fonts, tiny config).
- Hide it as soon as those complete.
Example (fonts + splash gating):
import { useEffect } from 'react';
import { useFonts } from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
SplashScreen.preventAutoHideAsync();
export function Root() {
const [loaded, error] = useFonts({
InterBlack: require('../assets/fonts/Inter-Black.otf'),
});
useEffect(() => {
if (loaded || error) SplashScreen.hideAsync();
}, [loaded, error]);
if (!loaded && !error) return null;
return null; // your app tree
}Anti-pattern:
- Keeping splash up while you fetch non-critical data that can load after first paint.
2) Confirm Hermes + treat engine upgrades as a perf lever
Hermes is the default JS engine in modern Expo/RN for good reasons:
- Often faster startup
- Lower memory usage
- Sometimes smaller app size
Make the choice explicit if you’re unsure:
{
"expo": {
"jsEngine": "hermes"
}
}Note:
- Changing the JS engine is not supported in Expo Go in modern SDKs; use a development build if you need to switch engines.
OTA updates + Hermes bytecode compatibility
If you ship OTA updates (expo-updates / EAS Update):
- Hermes compiles JS into bytecode.
- Bytecode format depends on Hermes version.
Operational rule:
- When you change Hermes/RN/Expo runtime such that bytecode compatibility can change, also manage
runtimeVersionso incompatible updates won’t load on old binaries.
3) Shrink JS evaluation (bundle size + module init)
Tree shaking hygiene
- Prefer ESM
import/exporteverywhere you control. - Avoid patterns that break tree shaking (notably: converting ESM to CommonJS).
- Watch out for Babel configs that apply
@babel/plugin-transform-modules-commonjs(or similar) to app code. - Guard dev-only code paths with
__DEV__/process.env.NODE_ENVso production bundles can drop them.
Optional (advanced): Expo unstable tree shaking flags
Some Expo setups support production-only tree shaking via environment flags during bundling/export. If you opt in:
EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1 EXPO_UNSTABLE_TREE_SHAKING=1 npx expo exportTreat these flags as an experiment:
- Validate behaviour in real release builds.
- Confirm correctness (no missing side effects).
Metro: inline requires (measure it)
inlineRequires can reduce startup cost by deferring module evaluation.
Metro config example:
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.transformer.getTransformOptions = async () => ({
transform: {
inlineRequires: true,
},
});
module.exports = config;Caveat:
- Inline requires can change execution order of side effects.
- Validate with production bundling and smoke tests.
Expo’s experimental tree shaking
Expo supports production-only tree shaking features that require:
- ESM modules
- Production bundling (e.g. via
npx expo export)
If you opt into unstable flags, do it on a branch and verify with real builds.
4) Android binary size knobs (don’t trade startup blindly)
With expo-build-properties, you can enable:
- R8 minification (
enableMinifyInReleaseBuilds) - Resource shrinking (
enableShrinkResourcesInReleaseBuilds) - JS bundle compression (
enableBundleCompression) — smaller APK, potentially slower startup
Example:
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"android": {
"enableMinifyInReleaseBuilds": true,
"enableShrinkResourcesInReleaseBuilds": true,
"enableBundleCompression": false
}
}
]
]
}
}Recommendation:
- Turn on minify + shrink first.
- Treat bundle compression as an experiment with a startup KPI before/after.
5) OTA update size: control assets and verify
If you ship OTA updates, large assets can dominate update size.
Key control:
updates.assetPatternsToBeBundled(or legacyextra.updates.assetPatternsToBeBundled) to include only certain assets in updates.
Example:
{
"expo": {
"updates": {
"assetPatternsToBeBundled": ["app/images/**/*.png"]
}
}
}Critical step:
- Run
npx expo-updates assets:verify <dir>to ensure all required assets are available for an update.
Optional (newer stacks): Hermes bytecode diffing for smaller OTA updates
On newer Expo SDK lines, Expo may support distributing OTA updates as binary patches (Hermes bytecode diffing). If OTA payload size is a major KPI for you, evaluate this feature via the Expo changelog/docs for your SDK.
Hermes V1 notes (2026-era stacks)
- React Native 0.84 makes Hermes V1 the default engine.
- Expo SDK 55 supports opting into Hermes V1 via
expo-build-properties(useHermesV1), but it can require building React Native from source (increasing build times) and may have known regressions.
Treat Hermes V1 as:
- A potentially high-impact performance lever, and
- A controlled rollout (branch, perf test, rollback plan).
Suggested deliverables for this domain
- A table of startup timings (cold/warm) before/after.
- A list of “startup blockers” removed or deferred.
- Bundle/OTA size report (JS bundle bytes + asset bytes).
Links
See references/resources.md for:
- Expo Hermes guide
- Expo tree shaking guide
- Metro config docs
- expo-build-properties
- EAS Update asset selection + runtimeVersion docs
JS thread, UI thread, rendering, and animations
Diagnosing the difference: JS vs UI thread
Think in two “FPS counters”:
- JS thread: React reconciliation, state updates, JS-driven animations, event handlers.
- UI thread: native layout/drawing, compositing, native animations.
Typical symptoms:
- JS thread stall → taps lag, JS-driven animations freeze, transitions feel slow.
- UI thread overload → scroll stutters even if JS is quiet; visual stutter under motion.
JS thread: highest-leverage fixes
1) Remove production logging
console.* can become a meaningful bottleneck in bundled apps (including logs from dependencies). Remove them in production builds.
Example Babel config:
{
"env": {
"production": {
"plugins": ["transform-remove-console"]
}
}
}2) Defer non-urgent work
Use InteractionManager to schedule heavy work after animations/interactions:
import { InteractionManager } from 'react-native';
InteractionManager.runAfterInteractions(() => {
// heavy work: parsing, expensive state derivations, etc.
});For tap handlers where visual feedback must appear first:
function onPress() {
requestAnimationFrame(() => {
doExpensiveAction();
});
}3) Reduce re-renders
Common causes:
- Parent state changes re-render entire lists.
- Context updates are too broad.
- Inline object/array props defeat memoisation.
Tactics:
- Split context providers.
- Stabilise props: memoise computed props, avoid inline functions in hot paths.
- Memoise row components (
React.memo) where it matters.
React Compiler (modern option)
React Compiler can automatically memoise many components and reduce manual useMemo/useCallback burden.
Expo flow (practical): 1) Run the compiler healthcheck (rule violations are common in older code):
npx react-compiler-healthcheck@latest2) Enable the Expo compiler experiment (exact packages/flags vary by Expo SDK; follow the Expo guide):
# often (SDK-dependent):
npx expo install babel-plugin-react-compiler@beta{
"expo": {
"experiments": {
"reactCompiler": true
}
}
}3) Profile key flows and keep a fast rollback path (feature flag / branch).
Use incremental adoption if needed (compile only certain files). For problematic files, use the opt-out directive (for example "use no memo"). Verify the compiler is active by looking for the compiler/memoisation indicators in DevTools (for example “Memo ✨” tags).
UI thread: highest-leverage fixes
1) Prefer native-driven navigation transitions
Using native stack / native screens generally yields smoother transitions because animations run on native/UI runtime rather than the JS thread.
Practical toggles (depending on your nav stack):
- Use
react-native-screensand ensure screens are enabled. - Consider
detachInactiveScreensandfreezeOnBlurwhere appropriate.
2) Avoid expensive compositing on animated frames
Classic offenders:
- Transparent text over images (alpha compositing).
- Heavy shadows, blur views, large overdraw.
Platform knobs (use carefully, profile memory):
- Android:
renderToHardwareTextureAndroid - iOS:
shouldRasterizeIOS(often enabled by default)
3) Don’t animate layout if you can animate transforms
Animating width/height of images can cause expensive recropping/resizing. Prefer transform scale:
style={{ transform: [{ scale }] }}Animations: choose the right mechanism
Animated API
- By default, keyframes are calculated on the JS thread.
useNativeDriver: truepushes animation execution to native, resilient to JS stalls.
Animated.timing(value, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}).start();LayoutAnimation
- “Fire and forget” layout transitions.
- Typically less sensitive to JS thread drops.
- Not suitable for interruptible/gesture-driven animations.
Reanimated
- Runs animations on the UI runtime via worklets.
- Great for gesture-driven and high-frequency animations.
Performance gotcha:
- Avoid reading shared values on the JS thread in tight loops; it can force synchronisation.
Suggested debugging order for jank
1) Record a performance trace. 2) If JS is busy at the moment of jank → reduce JS work (re-renders/computation). 3) If JS is idle but UI stutters → reduce view cost (layout/compositing), and check animations. 4) If still unclear → system trace (Android) / Instruments (iOS).
Links
See references/resources.md for:
- React Native performance overview
- Animated / LayoutAnimation docs
- Reanimated performance guidance
- react-native-screens documentation
- React Compiler docs + Expo guide
Lists, images, and media performance
Lists: the highest-ROI surface area
Large lists are a common “everything feels slow” amplifier:
- They increase render cost.
- They create re-render storms.
- They often include images (decode + cache pressure).
FlatList / VirtualizedList fundamentals
Always do these first
- Stable
keyExtractor. - Avoid re-creating
renderItemevery render. - Avoid passing new object/array props to every row.
- Keep row components pure and memo-friendly.
Add getItemLayout when item heights are known
If your rows are fixed height (or can be treated as fixed height), getItemLayout is a major win.
const ITEM_HEIGHT = 72;
<FlatList
data={data}
keyExtractor={(item) => item.id}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
renderItem={renderItem}
/>;Tune the window (measure trade-offs)
Knobs you may adjust (carefully):
initialNumToRenderwindowSizemaxToRenderPerBatchupdateCellsBatchingPeriodremoveClippedSubviews(often helps, but can cause bugs in complex layouts)
Trade-off:
- Smaller windows reduce memory and render cost, but can cause blanking when scrolling fast.
When to switch to FlashList (or similar)
If you have:
- Thousands of items,
- Heterogeneous row types,
- Very heavy rows,
…then a performance-optimised list library can be a step-change improvement.
FlashList baseline config
import { FlashList } from '@shopify/flash-list';
<FlashList
data={data}
estimatedItemSize={72}
keyExtractor={(item) => item.id}
renderItem={renderItem}
getItemType={(item) => item.type}
/>;FlashList success factors:
- Don’t benchmark in dev mode.
- Provide
estimatedItemSize. - Provide
getItemTypefor heterogeneous rows. - Memoise props aggressively for row stability.
Images: use a native pipeline, control caching
Prefer expo-image for image-heavy UIs
It provides:
- Disk + memory caching
- Placeholders (BlurHash/ThumbHash)
- Smooth transitions (avoid flicker)
Example:
import { Image } from 'expo-image';
<Image
source={{ uri: url }}
style={{ width: 96, height: 96 }}
contentFit="cover"
placeholder={{ blurhash }}
transition={150}
/>;Operational guidance:
- Decide a cache policy (disk vs memory) based on your UX.
- Prefetch images for the next screen when it’s cheap.
- For grids/feeds: ensure stable sizing to avoid relayout.
Video: caching and its limitations
Prefer expo-video (expo-av Video is deprecated)
expo-video supports caching (LRU policy), but note limitations:
- On iOS, caching may not work for HLS sources.
- DRM-protected content may not be cacheable.
Example:
import { VideoView, useVideoPlayer } from 'expo-video';
const player = useVideoPlayer(
{ uri: videoUrl, useCaching: true },
(p) => {
p.loop = true;
p.play();
}
);Operational guidance:
- Cache only replay-heavy media.
- Provide a “clear cache” option if storage pressure is plausible.
Debugging checklist for list/image jank
- Is the list virtualised (FlatList/FlashList), not ScrollView?
- Are row components re-rendering unnecessarily?
- Are images decoding at render time (no caching/placeholder)?
- Is there heavy overdraw (text over images, translucent layers)?
- Is UI thread FPS dropping even when JS is quiet?
Links
See references/resources.md for:
- React Native FlatList optimisation docs
- FlashList docs
- Expo Image and Video docs
Network, caching, and background work
Network performance: what actually matters
Users feel:
- “Screen is empty / spinner” time.
- Stalls when navigating between data-backed screens.
Your goal is usually:
- Fewer requests.
- Smaller payloads.
- Better caching and deduping.
- Predictable retries/backoff.
Measure first
- Use React Native DevTools Network tab where available (Expo tooling can enable network inspection).
- Capture p95 latency and “screen data-ready time”.
High-ROI implementation patterns
1) Avoid refetch storms
Common causes:
- Multiple components triggering the same request.
- Navigation focus effects refetching too aggressively.
Fixes:
- Deduplicate in-flight requests.
- Cache server-state data (stale-while-revalidate patterns).
2) Use HTTP caching where possible
- ETags, Cache-Control, CDN caching.
- Treat “static-ish” resources differently from dynamic ones.
3) Consider Expo’s expo/fetch when you need consistency
Expo provides a WinterCG-compliant Fetch API with streaming support. Useful when you want consistent behaviour across environments.
import { fetch } from 'expo/fetch';
const resp = await fetch(url, { headers: { Accept: 'application/json' } });
const json = await resp.json();Background work (best effort, not guaranteed)
Mobile OS schedulers are aggressive about battery and background limits.
Prefer expo-background-task
- Android: WorkManager
- iOS: BGTaskScheduler
Expect:
- Deferrable execution.
- Not immediate.
- Platform conditions (power/network) can gate execution.
Skeleton:
import * as TaskManager from 'expo-task-manager';
import * as BackgroundTask from 'expo-background-task';
const TASK_NAME = 'sync-task';
TaskManager.defineTask(TASK_NAME, async () => {
// keep it short; handle failures
return BackgroundTask.BackgroundTaskResult.Success;
});
await BackgroundTask.registerTaskAsync(TASK_NAME, {
minimumInterval: 30, // minutes
});Avoid deprecated background APIs
If you still use expo-background-fetch, treat migration as part of modernising and improving reliability.
Links
See references/resources.md for:
- Expo
expo/fetchdocs - Expo background task docs
Regression control: budgets, CI gates, and monitoring
Performance is not a one-off exercise. Treat it like security:
- You don’t “fix it once”; you prevent regressions.
This file focuses on controls that are cheap to adopt and high leverage.
The minimum viable controls (do these)
1) Commit a perf budgets file
- Store budgets for:
- JS bundle size (bytes)
- Total update assets size (bytes)
- A handful of app KPIs (startup, nav p95)
Use assets/templates/perf-budgets.example.json as a starting point.
2) Add a CI gate for bundle/update size
Why bundle size gates matter:
- Bigger JS = slower parse/execute = slower startup.
- Bigger OTA payloads = slower update adoption.
A practical approach: 1) In CI, run an export:
npx expo export --dump-sourcemap --dump-assetmap
2) Compute sizes from the output. 3) Compare against budgets and fail if exceeded.
This is intentionally “dumb but reliable”. It catches accidental dependency bloat and asset explosions.
Scripts included in this skill (copy into your repo, e.g. scripts/perf/):
scripts/report-export-sizes.mjsscripts/check-budgets.mjs
3) Add production monitoring
At minimum:
- Crash reporting.
- Transaction/performance traces for key flows.
- Proper symbolication/mapping for release builds.
Choose a provider that fits your constraints (Sentry, Firebase Perf, Datadog, etc.).
Nice-to-have controls (next step)
4) Automated “scenario timing” tests
If you have E2E tests (Detox, Maestro, etc.), add a small number of perf assertions:
- “Open feed screen p95 < X ms”
- “Scroll feed for 5 seconds with no sustained jank” (harder)
This is more brittle than bundle size gates; do it only once your test harness is stable.
5) Release build cadence
Make it cheap to produce production-like builds frequently.
- Use build caching features where available.
- Run performance checks on nightly builds or before release branches.
Example GitHub Actions gate (generic)
This is a template you can adapt:
name: perf-gates
on:
pull_request:
jobs:
bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx expo export --dump-assetmap --dump-sourcemap
- run: node ./scripts/perf/report-export-sizes.mjs --export-dir dist --out .perf/sizes.json
- run: node ./scripts/perf/check-budgets.mjs --budgets ./perf-budgets.json --sizes .perf/sizes.jsonNotes:
- Adjust the export directory (
dist) to match your setup. - Some CI environments need extra config for iOS/Android native builds; this gate avoids native builds by using export output.
Links
See references/resources.md for:
- EAS Workflows / EAS Build
- EAS Build caching docs
- Monitoring provider docs
New Architecture and managed-workflow constraints
Why it matters for performance work
React Native’s New Architecture changes core internals:
- How JS ↔ native communication works (JSI / TurboModules)
- Rendering pipeline (Fabric)
- Scheduling across threads
For performance, this can:
- Reduce overhead on hot paths
- Improve responsiveness
- Change the trade-offs of certain libraries
Expo reality (modern SDKs)
In recent Expo SDK lines, the New Architecture becomes the default and (in newer SDKs) cannot be disabled.
Operational impact:
- Third-party native modules must be compatible.
- Some legacy debugging tools/workflows differ.
How to manage compatibility
- Run
npx expo-doctorto detect version mismatches and known issues. - Validate native module compatibility (React Native Directory where applicable).
- Prefer Expo Modules API / config plugins for native integration.
When to go native for performance
Stay managed if the bottleneck is:
- Re-renders, list configuration, images, bundle size, data fetching.
Consider native code (via Expo Modules API / prebuild) if you truly need:
- Heavy compute off the JS thread (image processing, codecs, crypto)
- Real-time audio/video pipelines
- High-frequency sensor processing
Key caution
New Architecture upgrades can be high leverage but also high risk. Treat them like a migration:
- Branch.
- Measure KPIs.
- Validate all critical flows.
- Keep a rollback plan.
Links
See references/resources.md for:
- Expo New Architecture guide
- React Native New Architecture docs
Resources (high signal)
This list prioritises primary/official docs first, then high-signal community docs.
React Native (official)
- Performance overview: https://reactnative.dev/docs/performance
- Profiling guide (Instruments / Android Studio Profiler): https://reactnative.dev/docs/profiling
- Optimizing JavaScript loading: https://reactnative.dev/docs/optimizing-javascript-loading
- Optimizing FlatList configuration: https://reactnative.dev/docs/optimizing-flatlist-configuration
- Hermes engine: https://reactnative.dev/docs/hermes
- Animated: https://reactnative.dev/docs/animated
- LayoutAnimation: https://reactnative.dev/docs/layoutanimation
- React Native 0.84 release post (Hermes V1 default): https://reactnative.dev/blog/2026/02/11/react-native-0.84
React Native DevTools
- DevTools docs (Performance + Memory panels): https://reactnative.dev/docs/react-native-devtools
Expo (official)
Measuring / debugging
- Debugging tools: https://docs.expo.dev/debugging/tools/
- Development mode vs production mode: https://docs.expo.dev/workflow/development-mode/
Startup / bundling
- SplashScreen: https://docs.expo.dev/versions/latest/sdk/splash-screen/
- Fonts: https://docs.expo.dev/versions/latest/sdk/font/
- Using Hermes: https://docs.expo.dev/guides/using-hermes/
- Configure JS engines: https://docs.expo.dev/guides/configuring-js-engines/
- Tree shaking: https://docs.expo.dev/guides/tree-shaking/
- Customising Metro: https://docs.expo.dev/guides/customizing-metro/
OTA updates
- expo-updates: https://docs.expo.dev/versions/latest/sdk/updates/
- Runtime versions: https://docs.expo.dev/eas-update/runtime-versions/
- Asset selection/exclusion: https://docs.expo.dev/eas-update/asset-selection/
Build-time knobs
- expo-build-properties: https://docs.expo.dev/versions/latest/sdk/build-properties/
Performance-sensitive modules
- expo-image: https://docs.expo.dev/versions/latest/sdk/image/
- expo-video: https://docs.expo.dev/versions/latest/sdk/video/
- expo/fetch: https://docs.expo.dev/versions/latest/sdk/fetch/
Background work
- expo-background-task: https://docs.expo.dev/versions/latest/sdk/background-task/
- expo-task-manager: https://docs.expo.dev/versions/latest/sdk/task-manager/
CI/CD
- EAS Build: https://docs.expo.dev/build/introduction/
- EAS Build caching: https://docs.expo.dev/build-reference/caching/
- EAS Workflows: https://docs.expo.dev/eas/workflows/ (may require navigating from docs search)
Modern platform architecture
- New Architecture in Expo: https://docs.expo.dev/guides/new-architecture/
- Continuous Native Generation (CNG): https://docs.expo.dev/workflow/continuous-native-generation/
Recent Expo changelog (Hermes V1 / bytecode diffing)
- Expo SDK 55 changelog: https://expo.dev/changelog/sdk-55
React / React Compiler
- React Compiler: https://react.dev/learn/react-compiler
- Expo guide: https://docs.expo.dev/guides/react-compiler/
High-signal third-party libraries
- FlashList: https://shopify.github.io/flash-list/
- Reanimated performance guide: https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/
- react-native-screens: https://github.com/software-mansion/react-native-screens
Profiling helpers (advanced)
- react-native-release-profiler (Margelo): https://github.com/margelo/react-native-release-profiler
Monitoring options (choose one)
- Sentry React Native: https://docs.sentry.io/platforms/react-native/
- Firebase Performance Monitoring: https://firebase.google.com/docs/perf-mon
- Datadog RUM for React Native: https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/reactnative/
Skill authoring resources (for maintaining this skill)
- Skill authoring best practices (meta_skill): https://github.com/Dicklesworthstone/meta_skill/blob/main/BEST_PRACTICES_FOR_WRITING_AND_USING_SKILLS_MD_FILES.md
- AgentSkills spec/home: https://agentskills.io/home
#!/usr/bin/env node
/**
* check-budgets.mjs
*
* Purpose:
* Compare measured size metrics (from report-export-sizes.mjs) to a budgets JSON file.
* Fails with non-zero exit code on budget violations.
*/
import fs from 'node:fs';
import path from 'node:path';
function parseArgs(argv) {
const args = { budgets: null, sizes: null, warnOnly: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--budgets') args.budgets = argv[++i];
else if (a === '--sizes') args.sizes = argv[++i];
else if (a === '--warn-only') args.warnOnly = true;
else if (a === '--help' || a === '-h') {
console.log('Usage: node check-budgets.mjs --budgets perf-budgets.json --sizes .perf/sizes.json [--warn-only]');
process.exit(0);
}
}
if (!args.budgets || !args.sizes) {
console.error('Missing required args. Use --help for usage.');
process.exit(2);
}
return args;
}
function readJson(p) {
const abs = path.resolve(process.cwd(), p);
const txt = fs.readFileSync(abs, 'utf8');
return JSON.parse(txt);
}
function formatBytes(n) {
const units = ['B', 'KB', 'MB', 'GB'];
let x = n;
let u = 0;
while (x >= 1024 && u < units.length - 1) {
x /= 1024;
u++;
}
return `${x.toFixed(u === 0 ? 0 : 2)} ${units[u]}`;
}
function main() {
const { budgets: budgetsPath, sizes: sizesPath, warnOnly } = parseArgs(process.argv);
const budgets = readJson(budgetsPath);
const sizes = readJson(sizesPath);
const budgetBundle = budgets.bundle ?? {};
const maxJs = budgetBundle.maxJsBundleBytes;
const maxAssets = budgetBundle.maxTotalAssetsBytes;
const maxOta = budgetBundle.maxOtaUpdateBytes;
// Approximate OTA/update payload size: everything except sourcemaps.
const approxOtaBytes = Math.max(0, (sizes.totalBytes ?? 0) - (sizes.sourcemapBytes ?? 0));
/** @type {{name: string, limit: number, actual: number, fmt: (n:number)=>string}[]} */
const checks = [];
if (typeof maxJs === 'number') {
checks.push({ name: 'JS bundle bytes', limit: maxJs, actual: sizes.jsBytes ?? 0, fmt: formatBytes });
}
if (typeof maxAssets === 'number') {
checks.push({ name: 'Total asset bytes', limit: maxAssets, actual: sizes.assetBytes ?? 0, fmt: formatBytes });
}
if (typeof maxOta === 'number') {
checks.push({ name: 'Approx OTA/update bytes (minus sourcemaps)', limit: maxOta, actual: approxOtaBytes, fmt: formatBytes });
}
if (checks.length === 0) {
console.log('No size budgets found in budgets file (budgets.bundle.*). Nothing to check.');
process.exit(0);
}
console.log(`\nPerf budget check:`);
console.log(`- Budgets: ${budgetsPath}`);
console.log(`- Sizes: ${sizesPath}`);
let failures = 0;
for (const c of checks) {
const ok = c.actual <= c.limit;
const status = ok ? 'OK ' : 'FAIL';
const pct = c.limit === 0 ? '∞' : `${((c.actual / c.limit) * 100).toFixed(1)}%`;
console.log(`- ${status} ${c.name}: ${c.fmt(c.actual)} / ${c.fmt(c.limit)} (${pct})`);
if (!ok) failures++;
}
if (failures > 0) {
const msg = `\nBudget violations: ${failures}`;
if (warnOnly) {
console.warn(msg);
process.exit(0);
} else {
console.error(msg);
process.exit(1);
}
}
console.log('\nAll checked budgets passed.');
}
main();
Scripts
These scripts are intended as templates you can copy into a project repo (e.g. scripts/perf/) to enable CI perf gates.
report-export-sizes.mjs
Computes size metrics from an expo export output directory.
Example:
npx expo export --dump-assetmap --dump-sourcemap
node scripts/perf/report-export-sizes.mjs --export-dir dist --out .perf/sizes.jsoncheck-budgets.mjs
Compares measured sizes to a budgets JSON file.
Example:
node scripts/perf/check-budgets.mjs --budgets ./perf-budgets.json --sizes .perf/sizes.jsonAdd --warn-only to avoid failing CI.
#!/usr/bin/env node
/**
* report-export-sizes.mjs
*
* Purpose:
* Compute size metrics from an `expo export` output directory.
* Designed to be dependency-free so it can run in CI.
*
* Typical usage:
* npx expo export --dump-assetmap --dump-sourcemap
* node scripts/perf/report-export-sizes.mjs --export-dir dist --out .perf/sizes.json
*/
import fs from 'node:fs';
import path from 'node:path';
function parseArgs(argv) {
const args = { exportDir: 'dist', out: null, top: 20 };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--export-dir') args.exportDir = argv[++i];
else if (a === '--out') args.out = argv[++i];
else if (a === '--top') args.top = Number(argv[++i] ?? '20');
else if (a === '--help' || a === '-h') {
console.log(`Usage: node report-export-sizes.mjs [--export-dir dist] [--out .perf/sizes.json] [--top 20]`);
process.exit(0);
}
}
return args;
}
function walk(dir) {
/** @type {string[]} */
const files = [];
/** @type {string[]} */
const stack = [dir];
while (stack.length) {
const cur = stack.pop();
const entries = fs.readdirSync(cur, { withFileTypes: true });
for (const ent of entries) {
const p = path.join(cur, ent.name);
if (ent.isDirectory()) stack.push(p);
else if (ent.isFile()) files.push(p);
}
}
return files;
}
function bytesOfFile(filePath) {
const st = fs.statSync(filePath);
return st.size;
}
function classify(filePath) {
const ext = path.extname(filePath).toLowerCase();
const base = path.basename(filePath).toLowerCase();
// JS bundles / bytecode
if (ext === '.js' || ext === '.bundle' || ext === '.hbc') return 'js';
// Sourcemaps
if (ext === '.map') return 'sourcemap';
// Common assets
if (['.png', '.jpg', '.jpeg', '.webp', '.gif', '.heic', '.avif', '.svg'].includes(ext)) return 'image';
if (['.mp4', '.mov', '.m4v', '.webm', '.mkv'].includes(ext)) return 'video';
if (['.mp3', '.aac', '.wav', '.m4a', '.ogg', '.flac'].includes(ext)) return 'audio';
if (['.ttf', '.otf', '.woff', '.woff2'].includes(ext)) return 'font';
// Metadata / configs
if (ext === '.json') {
if (base.includes('assetmap')) return 'assetmap';
if (base.includes('metadata')) return 'metadata';
return 'json';
}
return 'other';
}
function formatBytes(n) {
const units = ['B', 'KB', 'MB', 'GB'];
let x = n;
let u = 0;
while (x >= 1024 && u < units.length - 1) {
x /= 1024;
u++;
}
return `${x.toFixed(u === 0 ? 0 : 2)} ${units[u]}`;
}
function main() {
const { exportDir, out, top } = parseArgs(process.argv);
const abs = path.resolve(process.cwd(), exportDir);
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
console.error(`Export dir not found: ${abs}`);
process.exit(2);
}
const files = walk(abs);
const byType = new Map();
const fileEntries = [];
for (const f of files) {
const size = bytesOfFile(f);
const type = classify(f);
byType.set(type, (byType.get(type) ?? 0) + size);
fileEntries.push({ file: path.relative(abs, f), type, bytes: size });
}
fileEntries.sort((a, b) => b.bytes - a.bytes);
const totalBytes = fileEntries.reduce((acc, x) => acc + x.bytes, 0);
const jsBytes = (byType.get('js') ?? 0);
const assetBytes =
(byType.get('image') ?? 0) +
(byType.get('video') ?? 0) +
(byType.get('audio') ?? 0) +
(byType.get('font') ?? 0);
const sourcemapBytes = (byType.get('sourcemap') ?? 0);
const result = {
exportDir: exportDir,
totalBytes,
jsBytes,
assetBytes,
sourcemapBytes,
fileCount: fileEntries.length,
byType: Object.fromEntries([...byType.entries()].sort((a, b) => b[1] - a[1])),
topFiles: fileEntries.slice(0, top),
generatedAt: new Date().toISOString(),
};
// Human output
console.log(`\nExpo export size report: ${exportDir}`);
console.log(`- Total: ${formatBytes(totalBytes)}`);
console.log(`- JS bundles: ${formatBytes(jsBytes)}`);
console.log(`- Assets: ${formatBytes(assetBytes)}`);
console.log(`- Sourcemaps: ${formatBytes(sourcemapBytes)}`);
console.log(`- Files: ${fileEntries.length}`);
console.log(`\nTop ${Math.min(top, fileEntries.length)} files:`);
for (const e of fileEntries.slice(0, top)) {
console.log(`- ${formatBytes(e.bytes).padStart(10)} [${e.type}] ${e.file}`);
}
if (out) {
const outAbs = path.resolve(process.cwd(), out);
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
fs.writeFileSync(outAbs, JSON.stringify(result, null, 2));
console.log(`\nWrote JSON report: ${out}`);
}
}
main();