---
title: Renderer stall profiler (what the app was doing when it froze)
---

# The renderer stall profiler — recording what the app's window was doing when it froze

## What it is

### What it answers

"The app just froze for three seconds — **what was it actually doing?**"

The main window draws the app. When that drawing is slow — a long chat that re-renders itself on
every update is the usual cause — you feel it as typing and clicking hanging. The app has always
been able to tell you *that* a redraw was slow. It could not tell you *which part of the code* was
slow, because nothing was recording it.

This does. It watches the main window only while the window is struggling, and when a pause lasts a
second or more it saves a recording of what the code was doing, plus one line summarising the
heaviest parts.

### Why you would look here

- The app felt frozen and you want to know why, rather than guess.
- Someone asked you for evidence about a slow redraw.
- You want to check the recorder is actually working (it is quiet when the app is calm — see below).

## Where to find it

### Turning it on and off

It is **on by default**. Nothing to enable, no setting to find, no restart needed for the default.

To turn it off, set the environment variable `AMC_DISABLE_RENDERER_STALL_PROFILER=1` before starting
the app. To check whether it is currently running, run:

```
npm run perf:status      # find the "stall profiler" row
npm run perf:control     # lists it as a registered governor, with its switch name
```

### Where the files go

Everything lands in the app's own logs folder — the same one that holds `renderer-diag.log`:

```
<app data>/logs/renderer-profiles/<timestamp>-<what>.cpuprofile
```

Only the newest 25 are kept, and there is a total size cap, so this cannot fill a disk.

## How it behaves

### Reading one

Open the `.cpuprofile` file in **Chrome DevTools → Performance → Load profile**. It shows a flame
chart of what ran and how long each part took.

**The names will look short and strange** — something like `nr (index-abc123.js:4567)` rather than
`renderTurnGroup`. That is normal and not a fault: the app ships with its internal names shortened
to keep it small and fast, and that happens to every finished app. What you *do* get is the exact
file, the exact line, and which chunk of the app the code came from — and the chunk alone answers
the question this was built for (is this React's own machinery, or is it our code?). If you need
the readable name, the build also keeps a map file that turns that line number back into a name.

### Reading the log line

Each capture writes one line into `renderer-diag.log`, tagged `[renderer-stall-profile]`:

```
[renderer-stall-profile] trigger=redraw durationMs=3821 samples=920 window=45000ms file=...cpuprofile top: TurnGroup (index-abc.js:4568) 97% | ...
```

- `trigger` — what noticed the stall (`redraw`, `longtask`, or `freeze`).
- `durationMs` — how long the pause lasted.
- `samples` — how many snapshots the recording holds.
- `window` — how long the recorder had been watching when the pause hit.
- `top` — the five heaviest things it saw.

### Why the recorder says it could not measure something

If a pause happens while the recorder was **not** watching, you still get a line — it just says so:

```
[renderer-stall-profile] trigger=longtask durationMs=1500 samples=0 window=0ms unavailable=disarmed-at-trigger top: (profiler dark — not armed when the stall hit)
```

That is deliberate. A recorder that quietly stops working looks exactly like an app that stopped
being slow, and those are very different facts. The reasons you may see are:

| `unavailable=`              | What it means                                                        |
| --------------------------- | -------------------------------------------------------------------- |
| `kill-switch`               | Somebody turned it off with the environment variable.                |
| `no-window`                 | There is no main window to watch right now.                          |
| `debugger-owned-by-devtools`| You have DevTools open on the app window; close it and it resumes.   |
| `attach-failed`             | The window could not be attached to this time.                       |
| `arm-failed`                | It attached, but sampling would not start.                           |
| `disarmed-at-trigger`       | The pause landed before the recorder had started watching.           |
| `capture-failed`            | The recording came back empty.                                       |
| `write-failed`              | The recording could not be saved (usually disk).                     |

### What it does NOT do

- **It never changes how the app behaves.** It only watches. If anything about it fails, the app
  carries on exactly as if it were not there.
- **It only watches the main desktop window.** The same chat shown on the phone/web view is a
  different window, drawn by different code, and is deliberately out of scope — a slow phone view
  is not evidence about the desktop window, so mixing the two would produce confidently wrong
  answers.
- **It does not work on the main process.** That is a separate instrument with its own tape lines
  (`[stall-stack]`); this one is only about the app window's own drawing.

## Related

- `npm run perf:status` — the scorecard this reports into.
- `[perf:redraw]` lines in `main.log` — the per-redraw timing probe this listens to.
- The main-process equivalent: `[stall-stack]` lines in `heartbeat.log`.
