@codeleap/perf
v7.7.0
Published
Performance analysis tooling for CodeLeap template apps — boot-graph, imports, chunks, measure.
Readme
@codeleap/perf
Catch web performance regressions before they ship — and measure TBT/LCP/FCP reliably when
you do. A build/dev-time CLI for CodeLeap template apps that analyzes the boot bundle, traces bad
imports, runs Lighthouse, and gives you a single PASS/FAIL verdict — designed to gate CI, though
not currently wired into this repo's CI pipeline; today it's run manually (bun run perf).
bun run perf # = codeleap-perf analyze — the one command you runWhy
Boot performance is easy to break and hard to see. A single stray static import of a heavy SDK silently lands its whole module on the boot bundle:
import * as Sentry from '@sentry/nextjs'on a boot-reachable file → real boot weight, even if you only callinit()later (exact KB varies by SDK version — deferring the call doesn't defer the parse).- A root-barrel import (
@/components) drags Zod / react-aria onto the homepage. - Replacing a
dynamic import()with a static one removes a code-split boundary → the whole graph behind it lands on boot instead.
None of these throw an error. You find out from users, or a Lighthouse score that crept down. And
when you do measure, it's easy to draw the wrong conclusion: machine load swings TBT by 2× between
runs, and grepping a chunk for "@sentry/" false-positives on lazy-import call sites.
@codeleap/perf exists to make this fast, repeatable, and honest:
| You want to… | Command | Needs a server? |
|---|---|---|
| Know if a heavy lib is on boot (seconds) | boot-graph | no |
| See which third-party packages the boot graph pulls | imports | no |
| See chunk sizes + duplication of configured heavy libs across chunks | chunks | no |
| Get real TBT/LCP/FCP (by floor, not noisy median) | measure | yes |
| Explore boot CPU self-time by library (secondary signal — see below) | cpu | yes |
| Run everything + a PASS/FAIL verdict for CI | analyze | optional |
chunks's duplication check runs on config.markers — the same list boot-graph uses, but reports
any marker found in ≥2 boot chunks as a bundler dedup failure. Two things to know before adding a
marker purely for duplication tracking:
- It only catches what you thought to name in advance.
- The same list feeds
boot-graph, which treats any single occurrence as a hit. A marker added only for duplication will also makeboot-graphreport a false FAIL for the first (correct) occurrence — treat that as expected noise, not a regression.
cpu is real (it drives an actual CDP profile and fingerprints chunks by source) but it's a
secondary, exploratory signal, not a decision-making one — cpu self-time ≠ TBT, since a function can
cost real self-time while running entirely outside the >50ms blocking window. Use it to compare two
builds' composition; use measure's floor (the subtract method) to confirm any actual TBT impact.
See Measuring Correctly §Attribution gotchas.
It bakes in the methodology that keeps the numbers trustworthy — SDK-internal markers (not package-name substrings), comparison by floor, recorded machine load — documented in full in Measuring Correctly.
Install
In this monorepo it's a workspace package — already a devDependency of apps/web. In a forked
product app:
bun add -d @codeleap/perfThen add the script (already present in the template):
{ "scripts": { "perf": "codeleap-perf analyze" } }The codeleap-perf bin resolves from the installed package — it works in the monorepo and in a
forked app, with no relative ../../packages paths.
Quick start
# 1. build the app (the analyzers read .next/)
bun run build
# 2a. static-only — fast, no server, great for a pre-commit / quick check
bun run perf -- --no-server # or: codeleap-perf analyze --no-server
# 2b. full — start a prod server first, then measure too
bun run start & # serves :3000
bun run perf # static + Lighthouse floors + verdictanalyze runs the static checks always, adds measure (and --cpu if asked) when a server is
reachable at config.serverUrl, and exits 1 on FAIL so CI can gate on it.
Commands
codeleap-perf boot-graph # heavy libraries on the boot bundle (expect CLEAN) — static
codeleap-perf imports # third-party packages on the static boot import graph — static
codeleap-perf chunks # per-chunk sizes + cross-chunk duplication — static
codeleap-perf measure [runs] [--headless] # TBT/LCP/FCP, floor of N runs (default 6) — needs server; headed by default
codeleap-perf cpu # boot CPU self-time by library — needs server
codeleap-perf analyze [options] # the lot + a verdict; exits 1 on FAIL
--no-server skip measure/cpu even if a server is up (pure static run)
--cpu also run the CPU profile (slower, fuzzier than measure)
--headless drive the internal measure call headless (default is headed)
--runs N Lighthouse runs per route for measure (default config.measureRuns)
--json [file] write the full report to disk (default perf-report.json)measure launches a real, visible Chrome window by default — headless LCP has been observed to read
artificially low against the same build measured headed, so trust the headed number. Pass --headless
only when no display is available (e.g. a CI runner without Xvfb); never mix the two modes within
one comparison.
Reading the output
═══ perf report · 2026-06-20T15:05:45Z · load 1.98/2.22/2.28 ═══
boot-graph ✓ CLEAN — 19 chunks, 1463KB
chunks 1463KB total, 19 chunks, largest 233KB
imports 21 third-party on boot (72 files)
measure / score 97 · TBT 135⚠ · LCP 2262⚠ · FCP 1062 · CLS 0.003 (floor of 6)
─── VERDICT: ✗ FAIL (2 issue(s)) ───
⚠ /: TBT floor 135 > 100
⚠ /: LCP floor 2262 > 2200- The report header records machine load — a number without its conditions is meaningless (see Measuring Correctly §Avoiding context loss). Compare runs at similar load.
⚠marks a metric over its configured threshold; the verdict fails on any boot-graph hit or over-threshold metric.--jsonwrites the same data structured — your baseline anchor on disk.
Config
Defaults target a Next.js pages-router app and need no config. To override, drop
codeleap-perf.config.json at the app root:
{
"buildDir": ".next",
"srcDir": "src",
"bootEntry": ["src/pages/_app.tsx", "src/pages/_document.tsx"],
"aliases": { "@/": "src/" },
"routes": ["/", "/auth"],
"serverUrl": "http://localhost:3000",
"measureRuns": 6,
"cpuSettleMs": 4000,
"thresholds": { "tbt": 100, "lcp": 2200, "fcp": 1100 },
"markers": [
{ "name": "Zod", "pattern": "safeParse|ZodError" },
{ "name": "Sentry", "pattern": "BrowserClient|makeFetchTransport" }
],
"sigs": [
{ "name": "react-aria", "pattern": "react-aria|useFocusRing|VisuallyHidden" },
{ "name": "Sentry", "pattern": "sentry", "flags": "gi" }
]
}You only need the fields you're overriding — anything omitted keeps its default. markers and
sigs replace the whole default list rather than merging — copy the defaults from DEFAULT_CONFIG
and extend them if you want to keep the built-in entries.
markersmust use SDK-internal symbol regexes, never package-name substrings. Package names appear in lazyimport('...')call-site strings and false-positive. This is the single most common way to misread the boot graph — see Measuring Correctly §Attribution gotchas.
markers feeds both boot-graph (any hit anywhere is a fail) and chunks (a hit in ≥2 chunks is
duplication). sigs is only used by cpu — it controls how the CPU profiler labels each chunk's
source (library attribution, not leak detection); pattern is a RegExp source string, flags
defaults to 'g'.
Docs
- Measuring Correctly — the metrics, the gotchas, and the discipline that keeps a perf number trustworthy. Read before trusting any conclusion.
- Improving Boot Performance — change → why → verify playbook for TBT/LCP on a React/Next.js project.
- Command & Config Reference — full command reference, every config field, and the programmatic API.
apps/web/docs/PERFORMANCE.md— the boot-path invariants in apps/web that this tool protects.
Programmatic API
Every analyzer is exported for use in scripts or CI beyond the CLI:
import {
analyze, bootGraph, traceImports, analyzeChunks, measure, cpuProfile,
loadConfig, DEFAULT_CONFIG,
} from '@codeleap/perf'
const config = loadConfig()
const report = await analyze(config, { cpu: true })
if (!report.verdict.pass) {
console.error(report.verdict.issues.join('\n'))
process.exit(1)
}| Function | Returns |
|---|---|
| loadConfig(cwd?) | Merged config — codeleap-perf.config.json over DEFAULT_CONFIG, or defaults if no file. |
| bootGraph(config) | { bootChunks, totalBootKb, hits, clean } — static |
| traceImports(config) | { files, thirdParty: { pkg, files }[] } — static |
| analyzeChunks(config) | { totalKb, count, chunks, duplicated } — static |
| measure(config, runs?, opts?) | Promise<RouteMeasure[]> — one entry per route with tbt/lcp/fcp floor+median |
| cpuProfile(config) | Promise<CpuResult> — { scriptedMs, idleMs, byLibrary, topChunks } |
| analyze(config, opts?) | Promise<AnalyzeReport> — all of the above plus verdict: { pass, issues } |
opts for analyze: { noServer?, cpu?, runs?, headed? } — headed defaults to true.opts for measure: { headed? } — same default.
bootGraph, traceImports, and analyzeChunks are synchronous (read only the build output, no server). measure, cpuProfile, and analyze are async; analyze never throws for "no server" — it sets serverUp: false and leaves measure/cpu as null.
How it works
The static commands read Next's build-manifest.json to find the /_app + routes[0] chunks (the
"boot bundle" — pages router only), then scan them for marker regexes (boot-graph), walk the
static import graph from the boot entries (imports), and size the chunks (chunks) — no app
code runs. measure drives Lighthouse via its Node API + chrome-launcher (no npx/PATH
dependency) using mobile form factor + Slow-4G + 4× CPU throttle, launches Chrome once, and reuses
it across all N runs and routes (Lighthouse resets cache/storage per run). cpu drives headless
Chrome over CDP, profiles boot at 4× throttle, and attributes self-time per chunk via source
fingerprinting against config.sigs (no source maps required). analyze composes them and renders
a verdict.
Tests
bun testThe static analyzers (boot-graph, imports, chunks), loadConfig, and analyze's verdict are
unit-tested against fixtures in test/fixtures (a fake .next build + a fake source tree) — fast
and deterministic, no server needed. measure and cpu are I/O-bound (Lighthouse / headless Chrome)
and validated by running them against a live build.
Status
Phases 1–4 complete: static analyzers, measurement, unified analyze + JSON + CI exit code, and
docs. Possible follow-ups: an HTML report from the JSON, App Router support.
