Baselines and regressions
For AI agents: this page is Markdown at https://squiggle.sh/docs/performance/baselines.md. Every docs page is listed at https://squiggle.sh/llms.txt, and all of them are one file at https://squiggle.sh/llms-full.txt.
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:
squiggle flow checkout # run the flow on mainsquiggle 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:
squiggle perf diff --against firstsquiggle 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
squiggle perf diff --against mainperf diff main → mv6rxxns-1 ! flag "thermal" differs: current 1 vs baseline 0metric (p95) base cur deltaEvent-loop lag 41 ms 18 ms -56.1%Footprint 421.6 MB 523.9 MB +24.3%span base cur delta n base/curScreenTti 2.8 ms 19.2 ms +586.4% 28 / 4The 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.
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"]}squiggle perf diff --against main --assert perf-gate.json✗ 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:
Event-loop lag got faster and still breaks the limit: the gate checks how far a number moved, in either direction.
{ "maxMetricP95DeltaPct": { "EventLoopLagUs": 25, "FootprintBytes": 25 }, "maxSpanP95DeltaPct": { "ScreenTti": 25 }, "maxStartupDeltaPct": 25}squiggle perf diff --against main --assert perf-gate.json✗ assertion failed: maxMetricP95DeltaPct.EventLoopLagUs: -56.1% > 25%✗ assertion failed: maxSpanP95DeltaPct.ScreenTti: +586.4% > 25%✗ assertion failed: maxStartupDeltaPct (first frame): +32.3% > 25%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. 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 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); 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. 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.
squiggle perf budgetbudgets 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 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:
- 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.
See Assertions. For how long each step of a flow takes, see Benchmark a flow.