# Performance

Squiggle records performance whenever your app is connected: every flow run, recording and Studio session. There is no profiling mode to switch on before you reproduce a problem, because the data is already there.

## What is recorded

| From | What |
| --- | --- |
| Inside your app | Renders and why they happened, screens, gestures, network requests, storage and SQLite, the event loop, the JS heap and garbage collection. |
| Outside your app | CPU per thread, memory, thread count and thermal state, measured from the Mac. It costs the app nothing and keeps reporting while JS is blocked. |
| The simulator | Frame rate, frame time, dropped frames, memory pressure, keyboard transitions, and each request’s DNS, connect, TLS and first-byte time. Needs the app launched by Studio or squiggle up. |

It all goes into `.squiggle/perf/perf.db` in your project. Studio and the `squiggle` command read and write the same file, so a flow you ran in the terminal shows up in Studio, and the other way round.

## The Performance screen

Pick **Live** or any stored session from the session menu at the top; its refresh button picks up sessions recorded since. The screen opens on a dashboard: the session’s latest values, then a card for each section saying what it holds. Click a card to open the section.

If your project builds for iOS and macOS and both apps are connected, an **iOS**, **macOS**, **All** switch picks whose sessions you see. Picking a platform also switches Live to that app.

| Section | What it shows |
| --- | --- |
| Metrics | Every metric Squiggle recorded, by category. Single metric charts one over the session, with a plain description of what it measures underneath. Compare overlays several on one chart: start from a built-in preset, or pick lines, name them and press Save preset (Update preset for a name you already saved; the × deletes a preset). Drag across the chart to read a value. |
| Timeline | Gestures, renders, requests, long tasks and screen changes on one time scale, with frame drops and GC marked. Drag to scrub. Below it, the flamegraph of the session’s CPU profile: click a frame to zoom in, use − and + to zoom, ◀ and ▶ to move along a zoomed graph, and Reset zoom to see all of it. Capture profile records ten seconds of the connected app’s JS thread into the session; use the app while it records. |
| Resources | Memory growth between the two newest heap snapshots, and every request as a bar split into DNS, connect, TLS and waiting. Click a request for its details. Take heap snapshot takes one from the connected app: take one, use the app, take another, and the panel shows what grew. |
| Insights | Findings, worst first; click one for its data and evidence. This session shows what the session on view found; Recent sessions shows each problem once with how many recent sessions it came back in, and Open newest session opens the newest one that found it. Then native crashes, with Scan for native crashes, and the comparison with the baseline. |
| Data | Runtime SQL: **Run** (`Enter⌘ ↵`) runs the query, and **Save current query** names and keeps it. Export trace, Copy report, Symbolicate JS stacks and Sweep now; and Run via CLI, with buttons for a 10-second profile, a heap snapshot, a crash scan and symbolication. |

When you open a recording on the Replay screen, the Timeline moves with the replay’s playhead.

## From the terminal

```shell
squiggle perf report          # a summary of the latest session
squiggle perf top --watch     # the newest value of each metric, live
```

Each `perf` command reads the newest session unless you pass `--session <id>`.

One launch of your app is one session, whether Studio, the terminal or both are watching it. A flow you run from the terminal with Studio open is stored once, and shows up in Studio under the same session.

A flow that relaunches the app on the way to its start screen, with `reset: relaunch` or when `auto` falls through to a relaunch, records two sessions: the app before the relaunch and the app after it. The newest is the relaunched one. Its launch happens before the flow reaches its first screen, so its startup frames aren’t charged to any screen’s frame budget.

## Find a performance cliff

A list that scrolls well with 50 rows can fall apart at 500. To find the size where it starts dropping frames, make the size a variable in your flow, and let `--cliff` run the flow at sizes across a range:

```yaml
flow: long-list
env:
  N: "50"
steps:
  - openLink: myapp://orders?count=${N}
  - scroll: { target: { testID: orders-list }, direction: down, amount: 2000 }
```

```shell
squiggle flow long-list --cliff N=10..800
```

Output:

```text
cliff long-list · N 10..800
    N  dropped frames
   10               7
   24               6
   58               3
   90               5
  139              10
  215              22
  333              38
  800              62
  ✗ cliff: frames start dropping at N≈139, then 26 more each time N grows by e (2.7×)
    most rendered there: OrderRow (412 renders) at src/orders/OrderRow.tsx:14
```

It runs the flow at six sizes spread from the low end to the high end, relaunching the app before each run, then two more near the point where dropped frames start climbing. It prints each size’s dropped frames, the size where they stop being flat, and the component that rendered most there. It exits 1 when it finds that point, and 0 when dropped frames stay on one line across the range. A run with no frame data is left out.

The component it names is one of yours, not a library’s. When a component and the ones inside it render about as often, as a list row and the text in it do, it names the outer one, because that is where the work per row starts.

### Keep a screen off its cliff

Say how big the screen must get without dropping frames, under its budget in `.squiggle/config.jsonc`. The name is the variable your flow sweeps:

```ts
"budgets": {
  "orders": {
    "sizes": { "N": { "max": 500 } }
  }
}
```

Every `--cliff` run is saved in `.squiggle/gate/cliffs.jsonl`. The [report card](https://squiggle.sh/docs/run/card) reads the newest one for each size a screen declares:

- Frames start dropping at or below `max`: the row fails, with the component that rendered most there.
- Above `max`, or no cliff up to it: the row passes, with how far past it the cliff is.
- The screen’s route file, or the file of that component, changed since the sweep, or no sweep reached `max`: the row is pending until you run the sweep again.

If the last two sweeps of the same code put the cliff more than 1.5 times apart, the row warns instead of failing, because the runs disagree. Run the sweep again on a quieter machine.

With [state samples](https://squiggle.sh/docs/reference/sdk#turn-on-state-samples) on, add the state that holds the size, for example `"state": "zustand:useOrders.items#len"`. The sweep and the card then say how many real sessions reach the cliff: `4 of 9 real sessions reach N ≥ 139`. Sessions your flows ran don’t count. Under three sessions that held the size, they say there are too few instead.

## Tune a constant against measured frames

A list’s window size, a page size or a debounce is usually a number someone picked once. Declare it with [`defineTunable`](https://squiggle.sh/docs/reference/sdk#definetunable), write a flow that scrolls the screen it changes and ends with `assert: { scroll }`, and let `--tune` choose the value by measuring it. Here it is on the example app’s Tune Lab, a list of slow rows whose `windowSize` is declared as `tune.windowSize`:

```yaml
flow: tune-lab
steps:
  - navigate: tune-lab
  - scroll: { direction: down, amount: 2400, target: { testID: tune-list } }
  - assert:
      scroll:
        droppedFrames: { gte: 0 }
        blank: { gte: 0 }
```

```shell
squiggle flow tune-lab --tune tune.windowSize
```

Output:

```text
tune tune-lab · tune.windowSize 1..21
tune.windowSize  objective  runs
              1       35.0     3
    2 (default)       29.4     5
              3       29.4     3
              …
              8       20.4     3
             10       36.4     3
              …
             21       19.4     3
  ! noise: 4.0 (the spread of 5 runs at the default)
  ! control: real evaluations to reach the chosen value: grid 7, random 4.3 (100 of 100 seeds), descent not reached
  ✔ chosen: tune.windowSize = 21: 18.4 against the default's 24.4 over five interleaved runs each
  .squiggle/tune/mvgkiw31-tune-ts3j.json · propose_fix { tuning: 'mvgkiw31-tune-ts3j' } turns it into an edit
```

It sets the value in the running app, then runs the flow without relaunching, because a relaunch puts the default back. Each run is scored on what its scroll measured: 1 per dropped frame, 0.05 per point of the tallest blank band, and 0 per millisecond of the run. Change the weights with `--tune-weights dropped=1,blank=0.05,ms=0`. A run that fails a step other than the scroll check scores worst, so no value is chosen by breaking the screen.

It runs the default five times first, and the spread of those runs is the noise. Then it runs every value the tunable allows, `--tune-reps` times each (default 3), and prints each value’s median. It proposes the lowest one only when five more runs of it, alternated with five of the default, beat the default by more than the noise. Otherwise it says why it proposes nothing. The control line says how many runs a grid, random search and step-by-step descent would have needed to find the value.

Nothing in your app changes. The result is saved under `.squiggle/tune/`, and an agent turns it into an edit of the tunable’s `default` with `propose_fix { tuning }`. To start every run from a save slot instead of the flow’s prerequisite, pass `--tune-slot <name>`. `--tune` can’t be combined with `--fresh`.

> **Old data is cleaned up**
>
> Raw samples are rolled up into one-second and one-minute values and then removed, and old profiles and recordings are dropped when the store fills up. Incident recordings are kept. To clean up now, press **Sweep now** in the Data section, or run `squiggle perf gc`.

## More

- [Findings](https://squiggle.sh/docs/performance/findings): What the detectors report, and how to act on it.
- [Profiling, memory and crashes](https://squiggle.sh/docs/performance/profiling): CPU profiles, heap snapshots and native crash reports.
- [Baselines and regressions](https://squiggle.sh/docs/performance/baselines): Compare a session with a saved one, and fail CI on a regression.
- [Query the data](https://squiggle.sh/docs/performance/sql): Ask your own questions with SQL, and save the queries.
