# Find what breaks on a bad network

Run a flow once on a working network and again on a slow, failing or offline one, and see what your app did differently: the spinner that never leaves, the empty state shown for a server error, the request behind each.

These commands drive a simulator app that `squiggle up` has prepared. They turn on network capture themselves. Requests to Metro and to Squiggle are never degraded.

Each one chooses the network or the starting state for its own runs, so they don’t combine: `--twin`, `--fuzz` and `--race` each refuse the other two, `--chaos` and `--stubs`, and `--cache-twins` refuses `--twin`, `--fuzz` and `--fresh`. A clash stops before any run with exit code 2 (`E_TWIN_CLASH` or `E_RACE_CLASH`).

## Compare a flow with its twin

`--twin` runs the flow on a working network, then once under each profile you name: `slow`, `flaky`, `truncated`, `offline-midstep`, or `all`. The profiles are described in [Run settings](https://squiggle.sh/docs/run/settings). Each run is recorded, and the report compares every degraded run with the clean one:

```shell
squiggle flow orders --twin all
```

Output:

```text
twin orders · 4 against a clean run
  ✔ slow: no difference
    no difference under slow: the app behaved as it did on a working network
  ! flaky: 1 finding
    under flaky, appeared only here: orders-empty — GET https://jsonplaceholder.typicode.com/todos returned 500
  ! truncated: 3 findings
    under truncated, orders-loading was still on screen when the run ended, 1.2 s after it appeared — GET https://jsonplaceholder.typicode.com/todos returned 200 with a body that does not parse
  run-failed: step 4 (assert) failed: visible text=Loaded 2 times: fail (no matching mounted node)
  missing: never appeared: order-* (×20), orders-count
  ! offline-midstep: 2 findings
    under offline-midstep, orders-loading was still on screen when the run ended, 0.3 s after it appeared — GET https://jsonplaceholder.typicode.com/todos failed on the network
  run-failed: step 4 (assert) failed: visible text=Loaded 2 times: fail (no matching mounted node)
  .squiggle/twins/mve8yhe6/report.json
```

| The report says | What it means |
| --- | --- |
| `was still on screen when the run ended` | A loading indicator never went away. The screen has no way out of its loading state when a request fails. |
| `step N failed` | The clean run passed and this one failed, at that step. |
| `never appeared` | Controls the clean run showed and this run never did. Numbered rows are counted, as in order-* (×20). |
| `appeared only here` | Controls only this run showed: often an empty state shown in place of an error. |
| `went to …` | A screen the clean run never reached, such as an error screen. |

Every finding names the requests that came back differently: the ones that failed, returned 5xx, returned a body that doesn’t parse, or arrived at least a second later than in the clean run.

A flow can pass under a profile and still have a finding. In the example above the flaky run passed, but the screen said “No orders yet” for a server error. Read the findings, not only the verdict.

## Break one endpoint at a time

```shell
squiggle flow orders --fuzz
```

`--fuzz` runs the flow once on a working network, then breaks each endpoint it called, one at a time, while every other request still reaches your server:

| Mutation | What the app gets |
| --- | --- |
| `500` | A server error with a JSON error body. |
| `empty` | A 200 with an empty list or object, matching what the endpoint returned. |
| `cut off` | The real response, cut off halfway through its body. |
| `5 s late` | The real response, five seconds late. |
| `404` | Not found, with no body. |

Each mutation is a full run, so a flow that calls four endpoints is twenty runs. `--fuzz-max` caps them (10 by default), first endpoint first. If the clean run made no request that came back, the command says `nothing to vary` and exits 0.

## Find races

A race is a tap the user repeats, or a Back they press, while the tap’s request is still in flight. `--race` searches for the two that break a screen whatever the timing: a request that changes something (anything but a GET) sent more times than in a run with no racer, and a late response that navigates after the user went Back.

```shell
squiggle flow checkout --race
```

Output:

```text
race checkout · 16 racers · window 2000 ms
  ✗ duplicate-mutation: POST api.example.com/orders sent 2 times, 1 in the baseline
    step 4 · repeat at 200 ms
  ! minimal duplicate-mutation: 1 step(s) before the tap, repeat at 0 ms, reproduced 3 of 3
    .squiggle/races/mve9k2a1/duplicate-mutation.min.yaml
  .squiggle/races/mve9k2a1/report.json
```

1. One run on your real network records what each request returns. It has to pass.
2. From then on every request is answered from that recording 2 seconds late, so each tap has the same 2-second window on every run. A baseline run with no racer has to pass under that delay too.
3. For each tap that left a request in flight, a racer (the same tap again, or Back) fires at 10%, 50% and 90% of the window, and once just after the response. Racers that failed in an earlier sweep of this flow run first.
4. For each kind of finding, Squiggle drops every step it can from before the tap, moves the racer to 0 ms if it still fails there, and replays the result three times. It writes the shortest reproduction as `<kind>.min.yaml` beside the report.

Every run relaunches the app. `--race-max` caps the racers (16 by default). The report is `.squiggle/races/<id>/report.json`, and `--json` prints the findings. `squiggle perf labels` exports each race run marked synthetic, with the invariant it broke.

| Exit code | Meaning |
| --- | --- |
| `0` | No invariant broken, or the flow made no request, so nothing was pending. |
| `1` | A finding, or the recording or baseline run failed. Fix the flow or the app first in that case. |
| `2` | Combined with a flag it can’t run with. |

## Check what your cache saves

`--cache-twins` runs the flow from two [save slots](https://squiggle.sh/docs/reference/sdk#save-and-restore-your-apprsquos-state), one taken before anything was cached and one taken after, and lists each endpoint the two runs called with how often each called it:

```shell
squiggle save catalog-cold           # on a fresh launch
squiggle flow catalog                 # fills the cache
squiggle save catalog-warm
squiggle flow catalog --cache-twins catalog-cold,catalog-warm
```

Output:

```text
cache twins catalog · catalog-cold against catalog-warm
  ✔ cached: GET jsonplaceholder.typicode.com/albums: 1 cold, 0 warm — the warm state answered 1
```

| Result | What the warm run did |
| --- | --- |
| `cached` | Fewer requests: its saved state answered the rest. |
| `refetched` | As many requests: its saved state saved nothing for that endpoint. |
| `more` | More requests, often something clearing what it held. |
| `warm only` | Called an endpoint the cold run never did. |

Whether a refetch is wrong is yours to say: a list that must always be fresh should refetch. Both runs have to pass, and a slot that won’t load stops the command. A refetch is not a failure: the command exits 0. `--fresh` can’t be combined with it, because a relaunch wipes the state each slot restores, and nor can `--twin` or `--fuzz`, because it chooses what each run starts from itself.

## Read the report

The run prints where it saved everything: a folder under `.squiggle/twins/` with one recording per run and `report.json`. `--json` prints the reports instead. Compare any two of the recordings yourself with [`squiggle diff`](https://squiggle.sh/docs/replay/offline).

`--twin`, `--fuzz` and `--cache-twins` exit 0 when the clean run passes, whatever they find. To gate CI on a finding, read `report.json`.

> **When a twin can't run**
>
> The clean run has to pass: a twin compares against a run that works. If it fails, the command stops there with exit code 1 and runs nothing else. Fix the flow or the app, then run it again.
>
> The app must be one Squiggle can inject, launched on a simulator. If it was launched without network capture, the first degraded run fails before its first step and says so; run it again with `--fresh`.
>
> A run that loses its connection to the app partway through is run once more. A degraded network is the same every time and a dropped connection isn’t, so only a loss that happens again is reported, as a failed step.
