Back to Blog

Your demo video doesn't need a screen, a human, or a screen recorder

PlaywrightAutomationToolingTestingClaude Code

I wanted a short looping clip of a UI flow to drop into a blog post: a serveoptions font picker cycling the headline on the Ginormous Playground landing hero. The normal way to get that is to open a screen recorder, click through the flow, trim the result, notice the cursor did something dumb, and record it again. It is manual, it captures my messy desktop, and every take is slightly different. Worst of all it is not repeatable: change the UI next week and you re-perform the whole dance from memory.

The bet: a headless browser can produce that clip from a script, with no display attached, and the output is good enough to ship. The cost is about forty lines of Playwright you keep around. If the clip looks worse than a hand-recording or the script is more fuss than just hitting record, the bet loses.

The one decision: record the browser, not your desktop

The move is to stop capturing pixels off your screen and let the browser record itself. Playwright drives the real running app, clicks the controls on a timer, and writes the video straight out of the browser context. Your desktop, your cursor, and your window manager are never in frame because they were never involved.

const context = await browser.newContext({
  viewport: { width: 1280, height: 720 },
  recordVideo: { dir: out, size: { width: 1280, height: 720 } },
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "networkidle" });
const next = page.getByRole("button", { name: "Next font" });
for (let i = 0; i < 16; i++) { await next.click(); await page.waitForTimeout(820); }
await context.close(); // this is what writes the file

The clip below was made by exactly that script, headless, with nothing attached to the machine:

The numbers

Eighteen seconds, 1.2 MB after a VP9 re-encode, produced by one node command, identical every run. It dwells the same 0.82 seconds on each of sixteen states because a loop told it to, not because I have steady hands. Re-run it after a redesign and you get the same shot with zero re-performance. That last property, reproducibility, is the one a screen recorder can never give you.

The wrong prior: I thought "headless" meant no pixels

I assumed headless meant nothing rendered, and that to get a video I'd have to attach a virtual framebuffer (xvfb) or a fake display. That is the folklore, and it is wrong for modern Chromium. Headless Chromium renders into the recording with no display server at all. No xvfb, no Xvnc, no GPU. The same script runs on my laptop and on a CI box with no screen and produces the same file. The thing I expected to be the hard part did not exist.

The gotchas that cost me a take each

  • recordVideo goes on the context, not the page and not launch(). There is no page.startRecording().
  • Closing the context is what flushes the file. Read the directory before context.close() and you get a truncated or empty webm.
  • Playwright writes VP8. It plays everywhere but is heavier than it should be. Re-encode to VP9 to roughly halve it: ffmpeg -i in.webm -c:v libvpx-vp9 -b:v 0 -crf 34 -an out.webm.
  • Video resolution is recordVideo.size, not deviceScaleFactor. A 2x scale factor sharpens screenshots from the same run, but the video stays at the size you set.

What it is and isn't

This is for deterministic UI flows you can select and drive: a wizard, a picker, a state machine, a before/after. It is not for capturing the feel of a real person using the thing, it is not going to record a hover micro-interaction you cannot script, and it has no audio. If you need a human in the loop, record a human. If you need the same ten-second proof every time the UI changes, never record a human again.

Take it

The script is record-webm.mjs in the pr-screenshots cluster of workways, parameterized so you point it at a URL and optionally a selector to click on a timer:

npx workways add pr-screenshots
node scripts/record-webm.mjs --url http://localhost:3000 --out demo.webm \
  --click "button[aria-label='Next']" --steps 16 --dwell 820

The full gotcha list lives next to it in docs/methods/headless-webm.md. One command, no screen, same clip every time.