Performance optimizing web apps can be painful. Instead of staring at flame charts, download your Chrome performance trace and run it through perftale. It turns the mostly-noise trace into high signal insights. It is designed for use with web games, and has dedicated React support, but would be useful for any interaction or animation-heavy app.
If you check the DevTools Memory checkbox, perftale will flag a rising post-GC heap floor, track event-listener/DOM-node/document growth, and point at the JS running while memory grew.
JS self-time is reported two ways, because ranking functions alone has a blind spot: work done in callbacks and closures is reported as (anonymous) and splinters across many low-ranking rows, so a file whose whole cost sits in one scheduler callback can be the most expensive first-party file in the trace and never appear in the function table. JS ranks functions (which line to open); FILES ranks source files, first-party and dependencies separately (which subsystem is expensive, and how much of the frame belongs to the engine).
Requires Node 23.6+ (runs TypeScript natively, no build step) and pnpm.
pnpm installOptionally, there is an install.sh script that symlinks the perftale command into ~/.local/bin and the repo into your Claude skills directory. See Using as a Claude skill for flags and details.
Record a performance trace in Chrome DevTools and export it as JSON, then:
perftale <trace.json[.gz]>Options:
--fps <n>— override the auto-detected refresh rate--debug— include pipeline diagnostics (noise reduction, latency, dropped-frame clusters)--out <path>— write the structured summary JSON to<path>--json— write the summary JSON to.perftale/<trace>.summary.json
Example:
perftale ./my-trace.json.gz --jsonWorking in a clone without the global install? pnpm analyze <trace> runs the same thing.
perftale ships with a SKILL.md that teaches Claude Code
when and how to run it. ./install.sh (run during Installation)
registers it by symlinking the repo into ~/.claude/skills/perftale, so Claude
discovers SKILL.md and the CLI together. The CLI imports this repo's src/ and
node_modules, so the perftale command always symlinks back to the clone — it
can't run detached. Re-running the installer is safe: correct symlinks are left
alone, and anything it replaces is backed up.
Flags:
--copy— copySKILL.mdinto a standalone skill dir instead of symlinking the repo (the global command stays a symlink; the CLI can't run detached).BIN_DIR=~/bin ./install.sh— link theperftalecommand somewhere other than~/.local/bin(must be on yourPATH).
To pre-approve the analyzer so the skill runs without a permission prompt, merge
permissions.json into ~/.claude/settings.json under
permissions.allow. Restart Claude Code afterward so the new skill is discovered.
Once installed, drop a trace into your project (or give Claude its path) and ask something like "analyze this trace" or "why is this janky" — the skill triggers automatically, runs the analysis, and reads the summary back to find and fix the jank.
Running with --json (or --out <path>) writes a structured summary to
.perftale/<trace>.summary.json — a compact, timestamp-free digest meant to be read by an agent or diffed across runs.
The full output shape is declared as a single TypeScript type in
src/summary-schema.ts (the Summary interface, versioned by SUMMARY_SCHEMA_VERSION). Top-level keys:
| key | meaning |
|---|---|
schemaVersion |
artifact schema version; bumps on any shape change |
trace |
source trace filename |
verdict |
the conclusion — headline, what the frame is bound by, top hotspot, caveats |
frames |
refresh rate, dropped frames, freezes, and where main-thread frame time goes |
profile |
JS self-time hotspots by function and by source file, plus the app/dependency split (null if the trace has no CPU profile) |
tasks |
long main-thread tasks (>50ms) |
gc |
GC pause pressure and suspected allocators (null if no v8.gc data) |
memory |
retained-memory growth — heap floor, listeners, nodes, documents + leak suspects (null if no Memory counters) |
react |
component-render digest from React DevTools timing (null if absent) |
frameDrops |
per-freeze cause + a coincidence verdict (is GC/reflow/long-tasks to blame); null if none dropped |
size |
noise-reduction stats for the streaming pass (debug only) |