Skip to main content
Xum has built-in tools to find out why the app or its backend is slow. All of them are opt-in, except hang stacks on the desktop app. Paths on this page use ~/.xum, the default Xum home. If you set XUM_ROOT, Xum uses that folder instead.

Run xum api commands

Most steps on this page use xum api <namespace> <procedure>. Namespace and procedure names are kebab-case, and input fields become kebab-case flags, for example xum api perf-captures capture-now --process backend. xum api talks HTTP to a running Xum backend. It finds the backend in this order:
  1. XUM_SERVER_URL and XUM_SERVER_AUTH_TOKEN, when set.
  2. server.lock in the Xum home. The desktop app writes it for its local API server (127.0.0.1, random port), unless you set XUM_NO_API_SERVER=1. xum server also writes it.
  3. http://localhost:3000.
Keep the desktop app or xum server running while you use these commands.

Flight recorder

The flight recorder keeps the last 10 minutes of performance samples in memory: backend event-loop delay, garbage collection and heap, renderer long frames and slow interactions, and oRPC call timings. It works for the desktop app and for xum server.

Turn it on

Open Settings → Experiments and turn on Performance flight recorder. Or run:
To turn it off, run:
The change takes effect at once. No restart is needed. Xum stores the setting in feature_flags.json in the Xum home. Turning it off stops collection. The samples already recorded stay readable until they are 10 minutes old. A restart clears everything.

Read the samples

Redirect the output to a file as shown. Piped xum api output can stop early, so jq then reports incomplete JSON. This command works while the recorder is off. Check state first: collecting means the recorder runs, off means it is off, and failed means it stopped with an error in failure. Useful filters:
Timestamps (atMs, startMs, endMs, nowMs) are milliseconds on the performance clock (performance.timeOrigin + performance.now()). Compare them with nowMs to see how old an entry is.
Top-level keys: version, state, nowMs, failure (only when state is failed), backend, renderer, trips, rpc.The renderer sends its entries to the backend every 5 s. It does not send entries from before you turned the recorder on.oRPC procedure timings cover every transport: HTTP (including xum api), WebSocket, and the desktop app’s internal MessagePort. Flow-control waits come from WebSocket clients only.

Trips

A trip is a detected problem. Xum records it in trips, and some trips start a CPU profile.

Privacy and cost

The snapshot contains no chat content and no event text. It does contain oRPC procedure names and error codes, script URLs, function names, invoker strings, event names and element tag names. Each window sends its renderer samples to the Xum backend it is connected to. Samples and profiles stay on the machine that runs that backend unless you share them yourself. For the desktop app, that is your machine. For a browser connected to a remote xum server, that is the server. Xum sends nothing to any other service. When the recorder is off, Xum runs no observers or timers. When it is on, it costs about 1.4 ms of backend CPU per second, mostly for the 20 ms delay histogram. oRPC recording adds well under a microsecond per call. CPU profiles cost more (see below).

Triggered CPU profiles

When the flight recorder detects a stall, Xum records a short CPU profile of what runs next. The renderer sends long frames to the backend in 5 s batches, so a renderer capture can start several seconds after the frame. These captures are part of the Performance flight recorder experiment. There is no separate switch. Rules:
  1. Each capture records 8 s at 1 ms sampling.
  2. Each trip kind has a 10-minute cooldown. It starts when Xum admits a capture, including captures that end up skipped.
  3. Only one capture runs at a time. Xum drops trips that arrive during a capture.
A capture shows the activity after the trigger, not the stall itself. Its metadata label says activity after trigger. Use it to see what work follows a stall, for example repeated renders or retries.
Starting a backend profile pauses the backend. The pause is about 150 to 250 ms on a fresh xum server, 0.4 to 0.76 s on a long-running desktop backend, and up to 1.7 s on a loaded machine. Profile tools can rank the inspector post call high: that is this start pause. Xum ignores its own profiler pause when it detects stalls, so one capture does not trigger the next.

Capture a profile by hand

  1. Turn on the flight recorder.
  2. Run:
    --process is backend or renderer. --duration-ms is 1000 to 30000 and defaults to 8000. A renderer capture profiles the desktop app’s main window.
  3. The command returns the capture’s metadata when it ends. Check it: profileFile names the written profile, and skippedReason means Xum could not profile (see the skip reasons below).
Manual captures skip the cooldown. Errors:
  • PRECONDITION_FAILED with “perf captures need the perfFlightRecorder experiment”: turn on the flight recorder.
  • CONFLICT with “a perf capture is already in progress”: another capture is running. Wait and try again.
  • CONFLICT with “perf capture cancelled”: the flight recorder was turned off during the capture. Turn it back on and try again.

Find captures

The result is { dir, captures }, newest first. A capture with a profile is two files in ~/.xum/perf/captures/. A skipped capture (see the skip reasons below) has only the .json file.
  • <id>.cpuprofile: a V8 CPU profile in JSON. Chrome DevTools, speedscope and the offline analyzer can read it.
  • <id>.json: the metadata. Xum writes it last, so a .json file means the capture is complete.
An id looks like 20261003T013338383Z-1a2b3c4d. The folder is private to your user (mode 0700, files 0600). For triggered captures, Xum logs [perfCaptures] captured (info) when the capture ends and [perfCaptures] capture failed (warn) when it fails. Manual captures do not write these lines. There is no delete command. Delete the files by hand.
Metadata fields: version, id, kind (loop-delay-p99, long-animation-frame or manual), process (backend or renderer), trigger (the trip, or null for a manual capture), startedAtMs, endedAtMs, samplingIntervalUs, durationMs, xumVersion, platform, label, and, when present, skippedReason, profileFile, profileBytes.When Xum cannot profile, it writes only the metadata file with a skippedReason:Retention: Xum keeps the newest 20 captures that have a profile, within 200 MiB in total (by file modification time), plus up to 20 skipped records. There is no age limit. Xum removes leftover temporary files older than 10 minutes.

Hang stacks (desktop)

When the desktop app’s main window stops responding, Xum logs where its JavaScript is stuck. This is always on and needs no experiment.
  1. Xum logs [diag] renderer unresponsive.
  2. Xum asks the renderer for its JavaScript call stack, with a 2 s timeout, once per hang.
  3. Xum logs [diag] renderer unresponsive JS stack with {url, stack}, or [diag] renderer unresponsive JS stack unavailable with the error.
Find the lines in ~/.xum/logs/mux.log, or in the Output tab. The log file rotates at 10 MB and keeps mux.1.log to mux.3.log. Pop-out windows and browser clients of xum server are not covered.

Offline analyzer

scripts/perf/analyzeProfiles.ts ranks the hottest functions in one or more CPU profiles, or compares two sets of profiles. It runs offline, uses no network, and never fetches remote source maps. Run it from a Xum source checkout:
Inputs are files or folders, searched recursively. In a folder the analyzer reads *.cpuprofile files, and *.json files only when they look like a CPU profile, so it skips capture metadata. It reads V8 CPU profiles from Node, Bun, Chrome DevTools, Xum captures, and the perf E2E chrome-cpu-profile.json.

Example: capture and analyze a backend profile

  1. Capture a profile while you reproduce the slow action:
  2. List captures to see the folder:
  3. Print the leaderboard:
    The output starts with a # CPU profile hotspots title and a Profiles: summary line. Then it shows time per category (app, node_modules, internal, extension, gc, program, idle) and a Top 25 by self time table with the columns # | Function | Location | Category | Self ms | Self % | Total ms | Total % | Samples. Shares exclude idle time unless you pass --include-idle.
  4. Write folded stacks and open them in speedscope or flamegraph.pl:
    Each line is frame;frame;frame <sampleCount>, root to leaf. Weights are sample counts, not time. The summary goes to stderr.

Compare two sets of profiles

--baseline (repeatable) turns on diff mode. The positional paths become the candidate set. The analyzer compares each function’s self time per second of wall time, in the columns Baseline ms/s | Candidate ms/s | Change ms/s. --min-change <ms/s> hides smaller changes (default 1). Rows match on file, function, line and column, so a function that moved shows as one removed and one added row. Folded output is not available in diff mode.
Exit status: 0 on success, 2 on usage errors, and 1 on any other failure, for example no valid profile on a required side, an --out file that cannot be written, or a runtime error.

Session tapes

A session tape records what one full chat subscription received from the backend, with its original timing. Tapes are for local performance replay. Xum records full chat subscriptions from every client of the backend, not only the Xum UI: ACP sessions and other API clients that load a whole chat also create tapes.
Tapes contain your full chat with no redaction: message and reasoning text, tool inputs and outputs, attachments, errors and metadata, and plain workspace IDs. Keep them on your machine. Never share or upload them, and never attach them to issues or pull requests.
Turn it on in Settings → Experiments → Session tapes. It applies to chats you open after you turn it on. Xum writes tapes to ~/.xum/perf/tapes/ (mode 0700, files 0600) as <startedAt>-<workspaceIdHash>-<tapeId>.jsonl. What Xum records:
  • Only full replays: the subscription that loads a whole chat, for example when you open it.
  • Not reconnects, live-only subscriptions, or older history you load later with “load older”.
Xum keeps each tape in memory and writes it once, when the subscription ends. The last line records why it ended: A crash, or a quit that needs more than 1 s to write, loses the open tapes. Xum never writes a partial file.
A tape is JSONL (format version 3):
  1. A header line with tape, xumVersion, tapeId, workspaceIdHash, startedAt, masking: "none" and subscription.
  2. One line per event: {t, bytes, event}, plus meta when the event holds values that plain JSON cannot represent.
  3. A trailer: {t, end: {reason, truncated, droppedEvents}}.
Limits: 4 MiB per event, 32 MiB per tape, 64 MiB for the whole process, across open tapes and tapes still being written. At a limit Xum truncates the tape and sets truncated: true and droppedEvents.Retention: Xum keeps the newest 20 tapes within 200 MiB. There is no age limit. Turning the experiment off does not delete tapes. Delete ~/.xum/perf/tapes/ yourself.

Report a performance problem

Collect these and attach them to the issue:
  1. Numbers from the flight recorder. Paste excerpts of the snapshot, for example jq '.trips' and the last samples, not the full file.
  2. Analyzer output for your captures: the leaderboard, or a diff against a good run.
  3. The .cpuprofile files and their .json metadata from ~/.xum/perf/captures/.
  4. The [diag] renderer unresponsive lines from ~/.xum/logs/mux.log, for hangs in the desktop app.
Review everything before you share it. Profiles contain function names and script paths or URLs, as all V8 profiles do. Snapshots contain procedure names, script URLs and function names. Log lines contain the stack and page URL. Never attach session tapes.