Recording product demos with Playwright and no human
Why do fixed timeouts break automated demos?
Fixed timeouts break because they encode a guess about the network and the machine, and both change under you. The classic version is await page.waitForTimeout(2000) after a navigation. On your laptop with a warm cache, 2000ms is 1500ms of dead air on camera. On a cold CI runner rendering the same page, 2000ms is 400ms short - you capture a spinner mid-flight. The result is a demo that pauses awkwardly, then jump-cuts into a half-loaded dashboard.
You can't win by tuning the number. A bigger timeout wastes screen time on every fast run; a smaller one flakes on every slow run. The number is wrong in both directions at once because it's answering the wrong question. You don't care that 2 seconds passed. You care that the page stopped changing. So the whole recorder is built around one idea: settle-detection. Instead of waiting a duration, wait for the DOM to go quiet.
What "settled" actually means
A page is settled when nothing meaningful has mutated for a short quiet window. You watch the DOM with a MutationObserver injected into the page, reset a timer on every mutation batch, and consider the page ready when the timer survives, say, 350ms untouched - plus network idle, because a settled DOM that's still waiting on an XHR is about to un-settle.
// runs inside the page via page.evaluate
async function waitForSettled(quietMs = 350, maxMs = 8000) {
return new Promise<void>((resolve) => {
let last = performance.now();
const obs = new MutationObserver(() => {
last = performance.now();
});
obs.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
const started = performance.now();
const tick = () => {
const now = performance.now();
if (now - last >= quietMs) {
obs.disconnect();
resolve();
} else if (now - started >= maxMs) {
obs.disconnect();
resolve();
} // hard ceiling
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}
The maxMs ceiling matters more than it looks. Some pages never fully go quiet - a looping CSS animation, a polling badge, an SSE connection nudging a counter. Without the ceiling, settle-detection waits forever for a page that's never going to be still. So it's settle-with-a-deadline: quiet wins if it can, the clock wins if it can't. That single fallback is the difference between a recorder that's robust and one that hangs on a <marquee>.
Why is a demo recorder really a state machine?
A recorder is a state machine because every step depends entirely on the exact state the last step left the app in, and the hard bugs all live in the transitions, not the actions. I didn't set out to build one. I started with a flat script - click, wait, click, wait - and it worked until it didn't. Every time it broke it was because step N assumed a screen that step N-1 didn't actually produce.
Once I named the states, the failures got legible. The recorder moves through explicit nodes - boot, landing, demo-open, flow-step-k, result, teardown - and each transition has three parts: an action (the click), a settle (wait for quiet), and an assertion (prove we landed where we meant to). If the assertion fails, I don't blindly continue into a black recording. I know exactly which edge broke.
The assertion is the part people skip
The temptation is to trust that clicking a button lands you on the next screen. It usually does. But "usually" is how you get a 90-second video where seconds 40 through 55 are a modal that failed to open, recorded in full confidence. So every transition ends by asserting the target state exists before the camera is allowed to move on:
async function transition(page, edge) {
await edge.act(page); // e.g. click "Run demo"
await settle(page); // DOM quiet + network idle
const ok = await edge.assert(page); // target state visible?
if (!ok) throw new StateError(edge.from, edge.to);
return edge.to;
}
Modeling it as a machine also gave me retries for free. A flaky edge - a cold Supabase connection that 500s the first time, an auth redirect that resolves a beat late - can retry just that transition from a known state, instead of tearing down and re-recording the whole demo from boot. When you're rendering these for real, re-running one edge versus the whole flow is the difference between a 3-second recovery and starting over.
How do you keep an automated flow from looking robotic?
You add human-shaped pacing back in on purpose, after settle-detection has removed the accidental pauses. This felt backwards at first. I spent all this effort deleting dead air, then deliberately put some back - but the two kinds of waiting are different.
Settle-detection removes the pauses that come from not knowing the page is ready. Then I add small, intentional dwell before a click so a viewer's eye can land on the button before it depresses. The rule I settled on: settle to zero, then dwell to taste. Settle-detection guarantees the floor - we're never recording a spinner. The dwell is a fixed, small, deliberate beat layered on top, and because it sits on a known-quiet page it reads as a person deciding, not a script stalling.
Crucially the dwell is constant across machines; the settle absorbs all the variance. Fast runner or slow, the rhythm of the final video is identical, which is the whole point when these get cut together.
Settle-detection is a recording primitive, not a test trick
The waiting strategy that makes a good recorder is the same one that makes a good end-to-end test - which is why this generalizes past demos. A test that waits for settle instead of a timeout is a test that doesn't flake and doesn't waste minutes of CI on padding. The demo harness and the test harness want the identical thing: proof that the app reached a state, not proof that time passed.
That overlap is why the recorder lives next to the app in the Turborepo monorepo rather than in some separate video project. It drives the real Next 16 app against real Supabase and Stripe - the same stack a user hits. When FetchDue went live the weekend of July 18, the recorded run that proves it works - a real reply classified, a real answer drafted, a real email delivered - came out of this exact harness driving the live product. No mock, no staged screen. The camera watched Chrome do the thing a customer does.
What I'd tell someone building this
- Stop tuning the timeout and start watching the DOM. Almost every flaky wait in an automated flow is a fixed duration standing in for a state you could have observed directly. Replace it with settle-with-a-deadline - quiet window plus a hard ceiling - and the whole class of "worked on my machine, flaked in CI" problems goes away, because you stopped guessing at time and started measuring readiness.
- The state-machine framing is the other half. The moment you write down the states and make each transition assert its target, your failures tell you which edge broke instead of leaving you a black video and a shrug. And you get per-edge retries as a bonus, which is what makes recording against a real backend - where a cold connection occasionally hiccups - tolerable instead of maddening.
This recorder is one of the pieces inside Kynth Apollo, the layer where I solve a hard problem once and never solve it again. Every demo, every product screenshot, every proof-it-works run pulls from the same harness now. That's the quiet luxury of building this way: the second time you need a driven browser, you're not building - you're composing.
Comments
No comments yet. Start the discussion.