React Native Reassure Tutorial (2026): Performance Regression Testing in Expo

A hands-on Reassure guide for React Native and Expo: Jest setup, measurePerformance vs measureFunction, CI baseline diffs, and a Danger.js budget that actually catches regressions before merge.

Reassure Tutorial: RN Perf Testing 2026

Updated: September 17, 2026

Reassure is a React Native performance regression testing library that measures render times inside Jest, compares them against a stored baseline, and fails your CI build when the delta exceeds a budget. It's built by Callstack and runs on top of the React Native Testing Library, so you get statistically significant numbers without spinning up a device. That's the exact tool you want catching a slow re-render before it lands on a user's Pixel 6a. Honestly, I've used Reassure on three production apps in 2026 and it has flagged real regressions in every one, usually within the first two weeks of adoption.

  • Reassure 1.4+ runs entirely in Jest, no device or simulator required, and measures average render duration plus render count over N iterations.
  • The library ships two main APIs: measurePerformance for full component trees and measureFunction for pure functions or hooks.
  • A regression is any run where the current branch's mean render is more than 4 standard deviations above the baseline, adjusted for the sample size.
  • CI integration is a two-step dance: run the baseline against main, then run the current branch and diff the two JSON outputs with reassure --compare.
  • Danger.js posts a per-scenario comparison table straight to the PR (the same format EAS Workflows understands with a shell step).
  • Reassure does not replace end-to-end frame profiling on a real device; it catches JavaScript render regressions before they compound with native cost.

What is React Native Reassure and when should you use it?

Reassure is a performance regression harness that answers one question on every pull request: "Did the average render time of my component get slower?" It runs your component through @testing-library/react-native a configurable number of iterations (100 by default), records how long each render takes with Node's performance.now(), and stores the mean plus standard deviation in a JSON file. The next run compares against that baseline and prints a per-scenario diff.

So, why bother with this in 2026 instead of "just profile in Flipper"? Manual profiling doesn't scale. On a 30-engineer app I profiled last quarter, we shipped a memoization regression that dropped list scroll from 60 to 44 FPS on a Pixel 6a. The root cause was a two-line change to a context provider three components up the tree. Reassure would have flagged that PR with a 36% mean render increase on the affected list component. Manual profiling missed it because none of the engineers touching that file thought to profile before merge.

Use Reassure when you have components that are re-rendered frequently (list rows, keyboard inputs, chat messages), or when a small render cost multiplies (a card component used 200 times on a feed). Skip it for one-shot screens that render once and stay mounted, since the signal-to-noise ratio isn't worth the CI minutes.

How do you install Reassure in an Expo project?

Reassure runs on top of Jest and React Native Testing Library, both of which Expo ships in the default template as of SDK 54. If you haven't set up RNTL yet, install it first, then add Reassure as a dev dependency. I recommend pinning to reassure ^1.4 for the improved variance detection and Node 20+ support.

npx expo install --dev reassure @testing-library/react-native jest-expo
npx expo customize jest.config.js

Reassure needs its own Jest configuration because the perf tests must not share the unit test setup. Create a dedicated jest-reassure.config.js at the repo root:

// jest-reassure.config.js
const base = require('./jest.config.js');

module.exports = {
  ...base,
  testMatch: ['**/*.perf-test.tsx', '**/*.perf-test.ts'],
  // Reassure explicitly disables coverage; it distorts render timings.
  collectCoverage: false,
  // Warmup phase; the first 3 renders are always slower on cold Metro.
  testEnvironment: 'node',
  reporters: ['default'],
};

Add two scripts to package.json. The first runs the baseline (before your changes), the second runs the current branch and produces the comparison. The naming convention *.perf-test.tsx keeps performance suites out of your regular yarn test run. You really do not want a 100-iteration benchmark firing on every commit.

"scripts": {
  "test": "jest",
  "test:perf": "reassure --baseline",
  "test:perf:compare": "reassure"
}

Writing your first render benchmark with measurePerformance

The measurePerformance API takes a React element and an options object. It renders that element 100 times (by default) inside a fresh RNTL render() call, measures duration between mount and first commit, and reports the mean. Here's a real benchmark from a chat app I shipped in June 2026, a message row that turned out to have a memoization bug:

// ChatMessage.perf-test.tsx
import { measurePerformance } from 'reassure';
import { ChatMessage } from './ChatMessage';

const MESSAGE = {
  id: 'msg_1',
  text: 'Hey, did you push the branch?',
  authorId: 'user_42',
  createdAt: Date.now(),
  reactions: [],
};

test('ChatMessage - baseline render', async () => {
  await measurePerformance(<ChatMessage message={MESSAGE} />);
});

test('ChatMessage - with 12 reactions', async () => {
  const withReactions = {
    ...MESSAGE,
    reactions: Array.from({ length: 12 }, (_, i) => ({
      emoji: '👍',
      userId: `u_${i}`,
    })),
  };
  await measurePerformance(<ChatMessage message={withReactions} />, {
    runs: 200, // more iterations for the higher-variance case
  });
});

Run it with yarn test:perf --testPathPattern ChatMessage and Reassure will print the mean render count, mean render duration, and standard deviation. A clean run on my M2 MacBook Pro shows ~1.4 ms mean render for the empty case and ~3.8 ms with 12 reactions. Those numbers are what the CI diff will compare against on the next branch.

The runs option matters more than most tutorials admit. Reassure's default of 100 iterations is fine for components under 5 ms mean render. For anything faster (say, a leaf text component at 0.3 ms), bump runs to 500. Otherwise noise from GC pauses swamps the signal and every run looks like a regression.

Measuring hooks and pure functions with measureFunction

Not every regression is a render. A slow reducer, an expensive selector, or a naive useMemo dependency can burn 40 ms on the JS thread and never surface as a re-render. For that, use measureFunction. It behaves like measurePerformance but skips React entirely; it just calls your function N times inside a tight loop.

// selectors.perf-test.ts
import { measureFunction } from 'reassure';
import { selectVisibleThreads } from './selectors';
import { buildFakeStore } from './__fixtures__/store';

const store = buildFakeStore({ threads: 5000, users: 2000 });

test('selectVisibleThreads - 5000 threads, filtered', async () => {
  await measureFunction(() => selectVisibleThreads(store, 'unread'));
});

test('selectVisibleThreads - warm memoized case', async () => {
  // Prime the memo cache once so we measure hit-path performance
  selectVisibleThreads(store, 'unread');
  await measureFunction(() => selectVisibleThreads(store, 'unread'), {
    runs: 5000,
  });
});

For hooks, wrap them in a component and use measurePerformance, or use renderHook from RNTL combined with measureFunction. The catch: hook measurements include React's reconciler overhead, so you'll see 0.5–1 ms of floor cost even for a hook that does nothing. That floor is your baseline; a 0.6 ms hook becoming a 4 ms hook is the signal.

Interpreting Reassure output: means, dropped values, and variance

After yarn test:perf:compare, Reassure writes .reassure/output.json and prints a markdown-formatted table to stdout. The columns matter, so let me walk through a real one from a PR I shipped last month:

| Name                                | Render Duration          | Render Count |
| ----------------------------------- | ------------------------ | ------------ |
| ChatMessage - baseline render       | 1.4 ms → 1.5 ms (+7.1%)  | 1.0 → 1.0    |
| ChatMessage - with 12 reactions     | 3.8 ms → 5.9 ms (+55.3%) | 1.0 → 2.0    |
| ThreadList - 200 items initial      | 42.1 ms → 43.0 ms (+2%)  | 1.0 → 1.0    |

The middle row is the regression. A 55% increase in mean duration and a jump in render count from 1 to 2 says the reactions render path is now double-rendering. Classic sign of a missing dependency in a useMemo or an inline object in props. The top row's +7.1% is inside statistical noise and will get flagged as "insignificant" by Reassure's variance check.

Reassure classifies each scenario into significant, meaningless, or unknown based on the ratio of the delta to the pooled standard deviation. A "significant" mark means the odds of the delta being random noise are below ~1 in 63, and that's the number to gate CI on. Ignore anything marked "meaningless"; those are usually GC blips.

The dropStatistical option (new in Reassure 1.4) automatically trims the top and bottom 5% of samples. Turn it on globally in your Jest setup. It removed most of my flaky "regression" false positives in the first week I used it.

CI integration: baseline, current, and the compare step

The Reassure CI dance is three steps: check out main, run the baseline, then check out the PR branch, run again, and diff. GitHub Actions handles this with actions/checkout called twice. If you're on Expo EAS Workflows for CI/CD, the same pattern works with a shell step; EAS Workflows treats git checkouts as regular commands.

# .github/workflows/reassure.yml
name: Performance
on:
  pull_request:
    branches: [main]

jobs:
  reassure:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'yarn'

      - run: yarn install --frozen-lockfile

      - name: Baseline against main
        run: |
          git fetch origin main --depth=1
          git checkout origin/main -- .
          yarn install --frozen-lockfile
          yarn test:perf

      - name: Restore PR branch
        run: git checkout -- .

      - name: Current branch
        run: yarn test:perf:compare

      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: reassure-output
          path: .reassure/

Two things matter here: fetch-depth: 2 so origin/main is reachable, and running yarn install after every checkout so lockfile changes on the PR branch don't skew node_modules-dependent code paths. Skipping the second install has bitten me twice; always reinstall between baseline and current.

Enforcing performance budgets with Danger.js and EAS Workflows

Reassure ships a Danger.js plugin that reads .reassure/output.json and posts a formatted table as a PR comment. On the same 30-engineer app, Danger cut our "did anyone check the perf tab?" Slack noise to zero. The table shows up on every PR, unread.

// dangerfile.js
import { danger, warn, fail } from 'danger';
import { default as reassure } from 'danger-plugin-reassure';

reassure({
  inputFilePath: '.reassure/output.json',
});

// Custom budget: fail if any scenario regressed by more than 15%
const output = require('./.reassure/output.json');
for (const entry of output.significant) {
  const delta =
    (entry.current.meanDuration - entry.baseline.meanDuration) /
    entry.baseline.meanDuration;
  if (delta > 0.15) {
    fail(`${entry.name} regressed ${(delta * 100).toFixed(1)}%`);
  }
}

The 15% budget is where I've landed after two years of tuning across three apps. Below 10% and you'll drown in false positives from Node version bumps or CI runner variance. Above 25% and you'll ship real regressions because the gate is too permissive. Pick 15% as the default, then ratchet down per-component for hot paths like list rows.

The full Reassure documentation on the Callstack Reassure docs site covers advanced options like custom collectors and headless-Chrome-based render measurement, both of which are overkill for 95% of apps. Start with the defaults, tune runs, and enable dropStatistical. That gets you 90% of the value.

Common Reassure pitfalls and how to fix them

The number-one Reassure failure mode isn't the library, it's flaky benchmarks. If you're seeing "regressions" on unrelated PRs, one of these is almost certainly the cause. In order of how often I see them:

  1. Shared state between iterations. If your component mounts a Zustand store and doesn't reset it between renders, iteration 100 is doing 100× the work of iteration 1. Wrap the render in a fresh provider per iteration by using the wrapper option.
  2. Async effects escaping the measurement window. Reassure stops the timer at first commit, but a useEffect firing a state update after commit will trigger a second render Reassure doesn't count in duration but does count in meanCount. Watch for meanCount jumps.
  3. CI runner variance. GitHub Actions macos-14 runners are shared VMs; the same test can vary 20% run-to-run. Use runs: 300 for hot components and enable dropStatistical. If you need harder guarantees, self-host runners or move to the React Native performance monitor on a device farm.
  4. Different React versions between branches. If main is on React 18 and the PR bumps to React 19, every scenario will look "faster" or "slower" because the React reconciler changed under you. Always run Reassure on same-React-version diffs; if you're bumping React, treat that as the baseline reset.
  5. Testing with mocks that hide real cost. If your baseline mocks expo-image to a native <View />, you're measuring the mock, not your app. Reassure has no opinion about mocks (they're a Jest concern), but the more your test setup diverges from production, the less signal you get.

If you're combining Reassure with Hermes heap profiling and end-to-end testing on device, in my experience you'll catch roughly 80% of shippable performance regressions before merge. The remaining 20% are native-side (bridge saturation, image decode cost, GPU work) and require a physical device. No amount of Jest will surface them.

Frequently Asked Questions

Does Reassure work with React Native New Architecture?

Yes. Reassure runs entirely in Jest with a Node test environment, so the New Architecture, Fabric, and TurboModules never load. Measurements reflect JS-only cost, which is exactly what you want to isolate from native changes. If you also want native paint numbers, run device profiling separately.

How is Reassure different from React DevTools Profiler?

DevTools Profiler is interactive and single-run: you record one session and inspect flamegraphs. Reassure is automated and comparative: it runs N iterations against a baseline and fails CI on regressions. Use DevTools when debugging a specific slow render; use Reassure to prevent regressions from shipping.

Can Reassure detect memory leaks?

No. Reassure measures render duration and count, not heap growth. For memory leaks in React Native, use Hermes heap snapshots or the Chrome DevTools memory profiler. Reassure and a heap profiler are complementary, not overlapping.

How many iterations should measurePerformance run?

The default 100 works for components with mean render above 5 ms. For faster components (0.5–2 ms), bump to 300–500 runs to lift the signal above GC noise. Anything under 0.3 ms is inside measurement error; either measure at a higher level or use measureFunction instead.

Does Reassure work with Expo Go?

Reassure doesn't touch the Expo runtime; it runs in Node during Jest. So yes, it works whether your app targets Expo Go or an Expo development build. The only Expo-specific setup is making sure jest-expo is your Jest preset.

Carlos Mendoza
About the Author Carlos Mendoza

Mobile performance engineer who profiles for a living. Has spent more hours in Flipper than he'll admit.