native-ios-perf-testing

Test native iOS app performance autonomously from the CLI — measure FPS during scroll/interaction, drive touch input, capture screenshots, stream runtime logs, and run before/after A/B comparisons of code changes. Use this whenever the user reports iOS UI jank, dropped frames, choppy scroll, animation hitches, "feels slow", layout jitter, or asks to validate that a perf change actually helped on iOS — *especially* in React Native / Expo apps. Also use when the user wants to drive the iOS Simulator from the command line (taps, swipes, scrolls), wants to verify a visual change without manual interaction, or asks how to test something "without me having to do it manually". Triggers include phrases like "60fps", "scroll feels janky", "test on simulator", "automate iOS testing", "FPS counter", "is my list smooth", "before/after on iOS", and references to xcrun, simctl, idb, or xctrace. Do NOT use for web apps (use a browser preview instead) or for unit/integration test runners — this skill is specifically for live perf measurement of a running native iOS app.

boltss/skills2 installsMITSynced Aug 22

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: native-ios-perf-testing
description: Test native iOS app performance autonomously from the CLI — measure FPS during scroll/interaction, drive touch input, capture screenshots, stream runtime logs, and run before/after A/B comparisons of code changes. Use this whenever the user reports iOS UI jank, dropped frames, choppy scroll, animation hitches, "feels slow", layout jitter, or asks to validate that a perf change actually helped on iOS — *especially* in React Native / Expo apps. Also use when the user wants to drive the iOS Simulator from the command line (taps, swipes, scrolls), wants to verify a visual change without manual interaction, or asks how to test something "without me having to do it manually". Triggers include phrases like "60fps", "scroll feels janky", "test on simulator", "automate iOS testing", "FPS counter", "is my list smooth", "before/after on iOS", and references to xcrun, simctl, idb, or xctrace. Do NOT use for web apps (use a browser preview instead) or for unit/integration test runners — this skill is specifically for live perf measurement of a running native iOS app.
license: MIT
---

# Native iOS Perf Testing from the CLI

## When to use this skill

Reach for this when you need to **measure or validate iOS performance** without manual interaction. Typical situations:

- User reports janky scroll, dropped frames, or layout jitter in an iOS app.
- User just made a perf-related code change and wants proof it helped.
- User wants Claude to test on the simulator rather than asking them to scroll manually.
- User wants screenshots, logs, or numeric FPS data captured from a running iOS app.

The headline workflow is **measure FPS during a programmatically-driven scroll, before and after a code change**. Everything else (toolchain setup, screenshots, log streaming) supports that.

## Important constraint: simulator vs. real device

The simulator runs the JS thread on host CPU, so absolute fps numbers won't match a device. But the *shape* of perf problems — JS thread blocking, layout-shift jitter, recycler stalls — reproduces faithfully because it's the same JS code paths. Treat simulator FPS as a proxy for "did my change make things better or worse," not as a device-accurate fps reading.

If the user has a physical device and wants device-accurate hitch counts, see `references/xctrace-on-simulator.md` — there are real-device-only Instruments templates that work.

## Toolchain (one-time setup)

You need three things on the host machine: Xcode CLI tools (Apple-provided), `idb` + `idb_companion` (Facebook's CLI for driving the sim), and a way to inject a tiny FPS counter into the app under test.

If `idb_companion` isn't installed, set it up before doing anything else. Full instructions in `references/toolchain-setup.md` — read that file the first time you use this skill on a machine, or whenever an idb command fails with "bad interpreter" or "No Companion Connected". Subsequent runs on the same machine can skip that file.

## Core workflow: FPS A/B comparison

This is the workflow the skill is built around. It produces a clear before/after answer to "did the change help."

### Step 1 — Inject an FPS counter at the app entry point

Add a temporary `requestAnimationFrame` loop to the app's root layout (e.g., `app/_layout.tsx` for Expo Router, `App.tsx` for plain RN). Gate it on `__DEV__` so it never reaches production:

```tsx
// TEMP FPS instrumentation — remove after verification.
if (__DEV__ && typeof requestAnimationFrame !== "undefined") {
  let fpsFrames = 0;
  let fpsLast = Date.now();
  const fpsTick = (): void => {
    fpsFrames++;
    const now = Date.now();
    if (now - fpsLast >= 1000) {
      console.log(`[FPS] ${fpsFrames} (interval=${now - fpsLast}ms)`);
      fpsFrames = 0;
      fpsLast = now;
    }
    requestAnimationFrame(fpsTick);
  };
  requestAnimationFrame(fpsTick);
}
```

The `interval=Xms` field is the part that matters most. RAF callbacks should fire ~16.67ms apart on a healthy 60fps thread, and the counter logs once per second. **If `interval` stretches to 1500ms+, that's the smoking gun: the JS thread was blocked for the entire delta**. A "60 FPS" reading with a 1000ms interval is healthy; a "4 FPS" reading with a 1555ms interval is catastrophic JS-thread blocking — the only reason you got 4 callbacks at all is that the thread eventually unblocked.

Why this works: `requestAnimationFrame` callbacks run on the JS thread in React Native. When list rendering or layout work blocks JS, RAF starves. So this counter is a direct, sensitive signal for JS-thread responsiveness during scroll — exactly the dimension users feel as "choppy."

### Step 2 — Make sure Metro is serving the change and the app is connected

The dev-built simulator app fetches its bundle from Metro at `http://localhost:8081` on launch. Verify Metro is up and that bundling your edits doesn't error:

```bash
# Is Metro running?
lsof -i :8081 -P -sTCP:LISTEN

# Does the iOS bundle compile cleanly with current source?
curl -s -o /tmp/ios-bundle.js -w "status=%{http_code} bytes=%{size_download}\n" \
  "http://localhost:8081/node_modules/expo-router/entry.bundle?platform=ios&dev=true&minify=false"
```

A 200 status means your edits compiled. A 404/500 means there's an error in the source — fix it before running the test, otherwise the simulator will load stale code. (For non-Expo-Router apps, the entry path differs — look for a `main` field in `package.json` or the file matching `index.js`/`App.tsx`.)

If the user already has Metro running you'll see it on port 8081. If not, start it:

```bash
cd <app-dir> && bun run start --dev-client &
```

### Step 3 — Run the "after" measurement

```bash
# Kill any prior log capture so output is clean
pkill -f "log stream.*FPS" 2>/dev/null

# Reload the app to pick up your edits, give it ~6s to settle on the target screen
xcrun simctl terminate booted <bundle.id>
sleep 1
xcrun simctl launch booted <bundle.id>
sleep 8

# Stream FPS lines to a file in the background
rm -f /tmp/fps-after.log
xcrun simctl spawn booted log stream --level=info \
  --predicate 'process == "<AppProcessName>" && eventMessage CONTAINS "[FPS]"' \
  > /tmp/fps-after.log 2>&1 &
LOGGER_PID=$!

# Drive the interaction you want to measure (scroll three swipes, 1s apart)
for i in 1 2 3; do
  idb ui swipe 200 650 200 200 --duration 0.3 \
    --udid <BOOTED_UDID> > /dev/null 2>&1
  sleep 1
done
sleep 2  # let post-scroll deceleration finish

kill $LOGGER_PID 2>/dev/null
grep -oE "\[FPS\][^()]*\([^)]*\)" /tmp/fps-after.log
```

Notes:
- The bundle id for `simctl` (e.g. `<bundle.id>`) and the **process name** for `log stream` (e.g. `TheApp`) are different. Find both with `xcrun simctl listapps booted | grep -B1 <known-string>`.
- `idb ui swipe` takes start_x start_y end_x end_y in **points** (not pixels). Get screen points from `idb describe`.
- Always `sleep 1` between swipes so the inertial scroll resolves before the next gesture, otherwise the second swipe rides the inertia of the first.

### Step 4 — Run the "before" measurement

Stash your changes, reload the app so the simulator pulls the old bundle from Metro, run the same scroll, capture to `/tmp/fps-before.log`. Then `git stash pop` to restore.

```bash
git stash push -- <changed-files>
# ... reload app and re-run Step 3, saving to fps-before.log ...
git stash pop
```

**Critically: relaunch the app between runs**, not just between code changes. Continuous scrolling sessions hit the bottom of the list and stop measuring real work. A fresh launch resets the scroll position and gives you a clean baseline.

### Step 5 — Read the comparison

Put both log outputs side by side. The fingerprint of a JS-thread blocking regression is:

- Idle: 60 FPS, interval ~1000ms (healthy)
- During scroll on the bad version: drops to 45/30/4 fps **with intervals stretching to 1500ms+**
- During scroll on the good version: stays 55-60 fps **with intervals staying near 1000ms**

The interval stretching is more diagnostic than the frame count — it directly shows JS thread block duration.

### Step 6 — Revert the instrumentation

Remove the FPS counter from `_layout.tsx` (or wherever you injected it) before declaring the task done. Run `git status` to confirm only the intended files are modified.

## Driving the simulator from CLI

The full set of useful commands lives in `references/idb-cookbook.md`. Read it when you need anything beyond a basic vertical scroll — taps, text input, hardware buttons, or descriptive accessibility queries.

The minimum set you'll use most:

```bash
# What's booted?
xcrun simctl list devices booted

# Apps installed on the booted sim
xcrun simctl listapps booted | grep -iE "(CFBundleIdentifier|YourApp)"

# Launch / terminate
xcrun simctl launch booted <bundle.id>
xcrun simctl terminate booted <bundle.id>

# Screenshot (PNG to disk; use Read to view)
xcrun simctl io booted screenshot /tmp/sim.png

# Stream app logs (filter by process and substring to keep volume sane)
xcrun simctl spawn booted log stream --level=info \
  --predicate 'process == "<AppProcessName>" && eventMessage CONTAINS "<token>"'

# idb (after companion is connected — see references/toolchain-setup.md)
idb ui tap <x> <y>             # tap at point (in pts)
idb ui swipe <x1> <y1> <x2> <y2> --duration 0.3
idb ui text "hello"
idb ui button HOME
idb describe                   # screen size in pts (use for swipe coords)
```

## What does NOT work on the simulator

Save yourself an hour: `xctrace record` with the **Animation Hitches** template returns "Hitches is not supported on this platform" on simulator. Also, `xctrace record --template "Time Profiler" --device <UDID> --attach <PID>` against a simulator process tends to **hang indefinitely** and produce empty traces — this is true across recent Xcode versions for several common templates. If you find yourself fighting xctrace + simulator, stop and use the RAF approach above. See `references/xctrace-on-simulator.md` for what does work, including paths that require a real device.

## Visual verification (when the change is observable, not measurable)

For "did the layout end up correct" rather than "is it fast", the workflow is simpler:

1. Reload the app: `xcrun simctl terminate booted <id> && xcrun simctl launch booted <id>`
2. Wait for the relevant screen to settle (`sleep 6-8`)
3. `xcrun simctl io booted screenshot /tmp/sim.png`
4. Open the file with the `Read` tool to see what's on screen.

If you need to navigate first, use `idb ui tap` or `idb ui swipe`. Combine with screenshots between steps to confirm you're on the right screen.

## A few things to internalize

- **Reload between every test run.** The most common reason for a misleading result is leftover state (scroll position, modal open, ad already loaded).
- **The `interval` field is the canary.** A 60 fps reading with stretched intervals means the thread was *idle* most of the second after a long block — measure the worst stretch, not the average.
- **Don't trust your screenshot for jitter.** Visual jitter is a temporal phenomenon — a still image can't show "items jumped 4 pixels and snapped back." For those symptoms, go to the code (FlashList's `maintainVisibleContentPosition`, async-loading components that grow, nested measurement) rather than looking for them in stills.
- **Be honest about limits.** If a perf claim isn't measurable from CLI on the sim, say so directly. The user can scroll manually on a device for the final gut check; you've narrowed the search space enough that they don't have to do it blind.

More Mobile skills

← All Mobile skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY