lightbringer

June 22, 2026 · View on GitHub

This is the narrative companion to the README. The README is the reference (every flag, every type); this page is for a human sitting down for the first time: how to measure, and how to read what comes back.

If you remember one thing: every number is broken down into network / CPU / render / memory / interaction / frames, all on one shared clock, so the only way to move a number is to change the implementation — not to change how the test waits.


1. Measure

There are three entry points. They all produce the same report and obey the same PERF_* flags — pick the one that fits how much you want to touch your tests. (Prefer to learn by running? The sample project is a self-contained app wired up all three ways.)

You name the regions you care about. Put the waitFor/expect inside the action so the span covers "until the operation is actually done".

import { test, expect } from "lightbringer";

test("home", async ({ page, perf }) => {
  await perf.measure("initial-load", async () => {
    await page.goto("https://example.com");
    await expect(page.getByRole("heading")).toBeVisible();
  });

  await perf.measure("open-search", async () => {
    await page.getByRole("button", { name: "Search" }).click();
    await expect(page.getByRole("dialog")).toBeVisible();
  });
});
pnpm exec playwright test

B. Auto-span (one-line import swap)

To put numbers on a spec you already have, swap the import. Every page.goto and every Locator action (getByRole(...).click(), locator(...).fill(), …) becomes its own span automatically — no other edits.

- import { test, expect } from "@playwright/test";
+ import { test, expect } from "lightbringer/auto";

Trade-off: an auto-span covers one action's own cost (the action plus a short settle), not "until your next assertion". When you need the wider window, use path A.

C. CLI (no install, no spec)

Describe the scenario as JSON and run it with npx — nothing to add to your repo:

{
  "url": "https://example.com",
  "viewport": { "width": 1280, "height": 800 },
  "steps": [
    { "name": "initial-load", "goto": "/", "waitFor": "h1" },
    { "name": "open-search", "click": "button[aria-label=Search]", "waitFor": "[role=dialog]" }
  ]
}
npx lightbringer run scenario.json
npx lightbringer run e2e/existing.spec.ts --config playwright.config.ts   # or reuse a real spec + config

2. Read the output

A summary is printed for every test (and the full report is written to perf-results/<title>.run<idx>.json). Here is a real report — a two-step scenario (load a heavy page, then scroll) measured on a laptop:

[perf] load and scroll
  vitals  LCP=924 (good)  INP=n/a  CLS=0 (good)  TTFB=261.9 (good)
    lcp ttfb=261.9 / load-delay=0 / load=0 / render-delay=662.1ms
    render-blocking: 7 css, 14 js  [//fonts.googleapis.com/css2?family=Roboto…, /cssbin/www-main…]
  initial-load                3877.4ms
      net   busy=1122.2ms  136reqs  5waves  3738.5KB
      3p    101reqs  125.4KB  busy=510.8ms  [gstatic.com 77.5KB, google.com 28.6KB, googleapis.com 17.8KB]
      ↳ from (anonymous)  k=…kevlar_base…:26096 (88)  /:21 (10)  k=…kevlar_base…:3632 (7)
      cpu   block=271ms  longtasks=1  maxTask=271ms  loaf=2/230.7ms
      frames 109fps  15 dropped  longest=274.9ms
      render style=62/14ms  layout=25/13.5ms  nodes=7045  script=387ms
      mem   heap=40MB (+39.4MB)  arraybufs=87  listeners=1191 (+1191)  docs=+5  domNodes=7053  (pre-GC; set PERF_MEM=1 for retained-only deltas)
  scroll                      2023.8ms
      net   busy=0ms  0reqs  0waves  0KB
      cpu   block=0ms  longtasks=0  maxTask=0ms  loaf=0/0ms
      render style=0/0ms  layout=0/0ms  nodes=0  script=3.1ms
      mem   heap=46.3MB (+6.4MB)  arraybufs=87  listeners=1191 (+0)  docs=+0  domNodes=7053  (pre-GC; …)
  css   17 sheets / 21349 rules / 24978 selectors  ×  2025 DOM nodes  (large selector×DOM product — PERF_CSS=1 …)
  total network 136 reqs / 3738.5KB
    third-party 101 reqs / 125.4KB (3% of bytes) across 6 domains
  ! software GL (SwiftShader): GPU / render numbers are NOT real hardware. Use PERF_GPU=1.

In a real terminal this is colour-coded by importance (stripped here because the page is plain text): metric labels are dimmed so the values stand out, web-vital ratings are green / yellow / red, notable costs (a slow step, jank, over-fetch, a capped span) are yellow, and genuine problems (a broken budget, a likely leak, an invalid run) are red. Routine zero-cost lines are dimmed — present for completeness, not for your attention. Colour follows NO_COLOR / PERF_COLOR=0|1 and turns off automatically when the output isn't a terminal (CI logs, > file).

Line by line

Page-global header (measured once for the whole page):

LineWhat it tells you
vitals LCP/INP/CLS/TTFBThe Core Web Vitals with their good/needs-improvement/poor rating. INP=n/a just means no interaction happened.
lcp ttfb / load-delay / load / render-delayWhere the LCP time went. Here LCP=924ms is almost all render-delay (662ms) — the largest element was painted late, not slow to download. That points at render-blocking work, not the network.
render-blocking: N css, M jsResources in <head> that hold up first paint (stylesheets + non-async/defer classic scripts). The list names the worst offenders.

Per-span block (one per perf.measure / auto-span). The first line is name durationMs (plus (capped) if it hit the settle timeout):

LineWhat it tells you
net busy=… reqs waves KBTime the network was actually busy inside the span, request count, number of dependency waves (a deep waterfall = many waves), and bytes. If you see (net-saturated: busyMs ≈ window) the page never stopped loading (polling/ads) — busyMs is then a wait window, not a discrete cost.
3p …Third-party slice of that network (bytes / requests / busy time / top domains). Appears only when there are third-party requests.
↳ from …Which code issued the requests (initiator script + frame, with counts). The network equivalent of "who's responsible". Shown for deep/heavy waterfalls.
cpu block=… longtasks maxTask loafMain-thread blocking time, count and size of long tasks, and LoAF (long-animation-frame) blocking. This is "how long the main thread was stuck".
inp type=…ms (input / proc / present)Worst interaction in the span, split into input delay / processing / presentation. Shown only when an interaction occurred (see §3).
frames Nfps dropped longestFrame cadence. Only printed when there's an actual hitch (dropped frames or a frame gap > 33ms); smooth spans stay quiet.
render style=c/ms layout=c/ms nodes script=msStyle-recalc (count/time), layout (count/time), DOM node count, and script — the single most trustworthy number here (CDP ScriptDuration, ±1ms). With PERF_TRACE=1/PERF_GPU=1 you also get paint= and gpu=.
mem heap=…(Δ) arraybufs listeners(Δ) docs(Δ) domNodesMemory gauges and their change across the span. arraybufs is a count, not bytes. The (pre-GC) note means deltas still include uncollected garbage; add PERF_MEM=1 for retained-only numbers.

Page-global footer:

LineWhat it tells you
css … sheets / rules / selectors × … DOM nodesCSS×DOM capacity — the structural ceiling on style-recalc cost. A large selectors × domNodes product earns a warning; PERF_CSS=1 then shows which selectors are expensive.
total network … / third-party …Scenario-wide totals, and the third-party share of bytes across how many domains.
! … linesWarnings that make results suspect: software GL (SwiftShader → GPU/paint numbers are fake, use PERF_GPU=1), page errors during the run, the in-page collector never running, or a budget violation. Read these first.

In this sample the takeaway is immediate: the page is render-bound, not network-bound (LCP render-delay dominates, script=387ms and block=271ms in one long task), it ships a heavy DOM (7045 nodes) and a big stylesheet (24978 selectors), and the GPU numbers are not real because it ran on SwiftShader.


3. Patterns worth recognizing

Interaction latency (INP) — where the jank is. From a keystroke benchmark:

  type                         898.7ms
      cpu   block=391ms  longtasks=6  maxTask=69ms  loaf=5/19.2ms
      inp   keydown=72ms  (input 0.4 / proc 0.5 / present 71.1)  17 interactions

The 72ms is almost entirely present (71.1) — the handler is cheap, the repaint after it is what's slow. Input-delay-dominated would point at a busy main thread instead; processing-dominated would point at the handler itself.

Memory leak trend — a single span's Δheap is too noisy to call a leak. Repeat the same step with perf.measureRepeat(name, action, { times }) (use PERF_MEM=1) and lightbringer looks for distributed monotonic growth across the repeats:

  memory trends (across repeated steps):
    alloc x6  jsHeapUsedMB 12→21.2→30.4→39.6→48.8→58MB  +46MB (+9.2/step)  ⚠ likely leak
    alloc x6  jsEventListeners 192→212→232→252→272→292  +100 (+20/step)  ⚠ likely leak
    alloc x6  domNodes 55→75→95→115→135→155  +100 (+20/step)  ⚠ likely leak
    alloc x6  arrayBuffers 30→60→90→120→150→180  +150 (+30/step)  ⚠ likely leak

Clean linear climbs on heap and listeners and nodes is an unambiguous leak. The count-based series (listeners / nodes / arraybufs) are the most reliable signal — heap alone bounces with GC.

A clean span, for contrast — quiet is good:

  scroll                      2023.8ms
      net   busy=0ms  0reqs  0waves  0KB
      cpu   block=0ms  longtasks=0  maxTask=0ms  loaf=0/0ms
      render style=0/0ms  layout=0/0ms  nodes=0  script=3.1ms

No network, no blocking, no layout — the work this step claimed to do cost almost nothing. (frames and inp lines are simply absent when there's nothing to report.)


4. Make it trustworthy

A laptop on localhost hides real-world cost; a single run is noisy. Three levers (full details in the README):

  • Throttle — PERF_CPU=4 (mid-tier device), PERF_NET=fast-3g, PERF_GPU=1 (real GPU numbers instead of SwiftShader).
  • Median out the noise — pnpm exec playwright test --repeat-each=5 then node node_modules/lightbringer/scripts/median.mjs. Gate CI on the median, and prefer the stable metrics (script/scriptMs, request count, node count) over the jittery ones (duration, blocking).
  • Budgets & regression gate — declare per-span upper bounds (perf.measure(…, { budget })) for an absolute gate, or scripts/regress.mjs <baseline> <current> for a baseline-relative one (fail when something gets >N% worse).

Gotcha: the fixture dev-server port

The bundled bench fixtures (and the CLI's JSON scenarios with relative URLs) serve on port 5173 by default. If another Vite project is already on 5173, Playwright will reuse it and silently measure the wrong app (you'll see locator-not-found failures). Set PERF_PORT=<free port> to avoid the collision.