# Baselines and regressions

Save a session as a baseline, compare later sessions with it, and fail a CI build when a number moves too far.

## Save a baseline

A baseline is a saved summary of one session: its metrics, timings, startup phases and render counts. Run the flow you want to watch, then save the session it made:

```shell
squiggle flow checkout                        # run the flow on main
squiggle perf snapshot --label main           # save that session as "main"
```

Save again under the same label and comparisons use the newest one. In Studio, press **Snapshot as baseline** on the Performance screen’s **Insights** section. It saves the session you are looking at as `main`, and the panel then compares each session you pick with it: metrics, span kinds, startup and render counts, with a warning when the two ran in different conditions.

### The first baseline is automatic

The first time you run `squiggle up` in a project, it watches your app for a few seconds after launch and saves that session as a baseline named `first`, with the startup phases. It also keeps the recording, `.squiggle/recordings/first.sqrec`, so the cleanup never removes it. Compare any later session with it:

```shell
squiggle perf diff --against first
```

`squiggle up --no-baseline` skips it. To take it again, delete `.squiggle/baselines/first.json` and run `squiggle up`. If it can’t be recorded, `up` still succeeds and prints a warning, and the next `up` tries again.

## Compare with it

```shell
squiggle perf diff --against main
```

Output:

```text
perf diff main → mv6rxxns-1
  ! flag "thermal" differs: current 1 vs baseline 0
metric (p95)         base       cur     delta
Event-loop lag      41 ms     18 ms    -56.1%
Footprint        421.6 MB  523.9 MB    +24.3%
span                    base       cur    delta  n base/cur
ScreenTti             2.8 ms   19.2 ms  +586.4%      28 / 4
```

The diff lists each metric, span and startup phase that moved, with the findings for the new session. The line starting with `!` is a warning that the two sessions ran in different conditions, here a warmer Mac. Treat a diff with warnings as a hint, not a verdict.

> **Compare like with like**
>
> Save the baseline and run the comparison on the same simulator, with the same flow and the same build type. Timings from a different device or a Debug build against a Release one aren’t comparable.

A baseline remembers the platform it was saved on, and the diff compares it with the newest session on that platform. If your app builds for iOS and macOS, save a baseline for each, and pass `--platform macos` to `perf snapshot` to save one from your Mac app. A diff between an iOS session and a macOS baseline is refused: it would measure the platform, not your change. In Studio, the Performance screen’s **iOS**, **macOS** and **All** switch chooses whose sessions you browse. It appears when apps for both platforms are connected, or, with neither connected, when the project has sessions from both.

## Fail a build on a regression

Put your limits in a JSON file and pass it with `--assert`. The diff is printed first, then each limit it breaks, and the command exits 1.

```
{
  "maxMetricP95DeltaPct": { "UiFps": 10, "JsHeapBytes": 20 },
  "maxSpanP95DeltaPct": { "ScreenTti": 25 },
  "maxStartupDeltaPct": 15,
  "maxRenderDelta": { "CartRow": 3 },
  "failOnDetectors": ["long-task"]
}
```

```shell
squiggle perf diff --against main --assert perf-gate.json
```

Output:

```text
✗ assertion failed: maxSpanP95DeltaPct.ScreenTti: +586.4% > 25%
✗ assertion failed: maxStartupDeltaPct (first frame): +32.3% > 15%
✗ assertion failed: failOnDetectors: long-task insight "JS thread blocked 484ms — js_long_task"
```

| Key | Fails when |
| --- | --- |
| `maxMetricP95DeltaPct` | A metric’s 95th percentile moves more than this percentage, either way. Keys are metric names such as UiFps, JsHeapBytes or ProcCpuPct. |
| `maxSpanP95DeltaPct` | A span kind’s 95th percentile moves more than this percentage. Keys such as ScreenTti, Http or SqliteQuery. |
| `maxStartupDeltaPct` | Any startup phase moves more than this percentage. |
| `maxRenderDelta` | A component renders more times than in the baseline, by more than this. |
| `failOnInsightSeverity` | Any finding is at or above this severity: warn or error. |
| `failOnDetectors` | Any of these detectors reports a finding. |

A move counts in either direction. A number that got better by more than its limit fails too, which is your cue to save a new baseline. Drag the limit to see how the gate reads a diff:

Start with `failOnDetectors` and add one detector at a time, picking the ones `squiggle perf detector-stats` shows fire more on failed runs; see [Findings](https://squiggle.sh/docs/performance/findings). A severity floor gates every detector at once, including noisy ones.

## Set a budget per screen

A budget is a limit you write down for a screen before anything is slow. Budgets go in `.squiggle/config.jsonc`, keyed by route name. `"*"` applies to every screen, and a screen’s own entry overrides it one limit at a time. `squiggle init` writes a starting set.

```
"budgets": {
  "*": { "frameP95Ms": 16.7, "rendersPerTap": 60, "memoryCeilingMb": 400 },
  "checkout": { "frameP95Ms": 12, "startupToInteractiveMs": 800 }
}
```

| Limit | Measured as |
| --- | --- |
| `frameP95Ms` | The 95th percentile frame time while the screen is shown. |
| `droppedFramesPerInteraction` | Dropped frames in the second after a tap, for the worst tap. |
| `rendersPerTap` | Renders caused by the heaviest single tap. |
| `startupToInteractiveMs` | The screen’s time to interactive. For the first screen, the whole startup. |
| `nativeLaunchMs` | Cold start, on the screen the app opens on: from the process starting to your JavaScript starting. A development build downloads its bundle in this phase. |
| `bundleLoadMs` | Cold start: evaluating your JavaScript bundle, every module your app imports while it starts. |
| `firstRenderMs` | Cold start: from the bundle evaluated to React’s first commit. |
| `firstSettleMs` | Cold start: from the first commit to the first screen being interactive. |
| `firstDataMs` | Cold start: from the first commit to the first response to a request the app made while starting. Not measured when the app makes no request while starting. |
| `memoryCeilingMb` | Peak memory while the screen is shown. |
| `waterfallDepth` | The longest chain of requests where each one waited for the last. The [report card](https://squiggle.sh/docs/run/card#network) also fails a screen whose chain got deeper than in its last green run. |
| `longestAnimationMs` | Not measured yet. A budget on it always reports “not measured”. |

The cold-start limits apply to the screen your app opens on, and only in a session that started cold: `squiggle card` starts the app cold before its first flow. Set them on that screen’s own entry, not on `"*"`. Each phase starts where the one before it ends, so a slower bundle shows up in `bundleLoadMs` and not in every phase after it. The phases need your Metro config wrapped in `withSquiggle` (see [Set up your app](https://squiggle.sh/docs/setup#wrap-your-metro-config)); without it, `nativeLaunchMs`, `bundleLoadMs` and `firstRenderMs` can report “not measured”.

Budgets are used in two more places. The `waterfall-request` detector reports a chain longer than the `"*"` `waterfallDepth`, and `jank-attribution` reports a moment that dropped more frames than the `"*"` `droppedFramesPerInteraction`; see [Findings](https://squiggle.sh/docs/performance/findings). And a screen’s `frameP95Ms` and `droppedFramesPerInteraction`, its own or from `"*"`, are the limits a flow’s `scroll` and `smooth` asserts use when they leave a number out.

```shell
squiggle perf budget
```

Output:

```text
budgets every budgeted screen · session mv6rxxns-1
  ✔ checkout frameP95Ms: 10.4 of 12 ms, margin 1.6, lost 0.8 this week
  ✗ checkout rendersPerTap: 74 of 60 renders, margin -14
  · checkout waterfallDepth: not measured (limit 2 requests)
```

It checks the newest session, or the one you pass with `--session <id>`. Each line shows how far the screen is from its limit, and how much of that margin it lost or gained this week, so you can see a screen heading for its limit before it gets there. A limit nothing measured reports “not measured”, never a pass. The command exits 1 when any screen is over.

`squiggle perf budget --changed` checks only the screens your branch changed since it left `origin/main`, including edits you haven’t committed; `--base <ref>` compares with another ref. It runs the fewest saved flows that visit those screens, then checks their budgets against those runs. A changed screen that no flow visits is listed under “no flow covers” and isn’t checked. The [report card](https://squiggle.sh/docs/run/card) fails on it instead.

## Check performance inside a flow

A flow can assert on renders and frames at the step where they matter, so the run fails at that step rather than in a later comparison:

```yaml
- tap: { testID: add-to-cart }
- assert:
    rerenders: { CartRow: { lt: 3 } }
```

A `scroll` or `smooth` assert that leaves out its frame numbers takes them from the screen’s budget; see [Take the numbers from your budgets](https://squiggle.sh/docs/flows/assertions#take-the-numbers-from-your-budgets).

See [Assertions](https://squiggle.sh/docs/flows/assertions). For how long each step of a flow takes, see [Benchmark a flow](https://squiggle.sh/docs/run/bench).
