Stabilizing through skeletons.
A small but high-impact pattern: don't take a screenshot until the page is actually done loading. Reduced flaky visual tests by roughly half on the Visual Diff Analyzer. Spec-style writeup of the wait-for-stable pattern.
The trap
You add a visual regression test. It passes. Tomorrow it fails. Today the screenshot has skeleton tiles. Yesterday it had the real content. Same URL, same browser, same viewport.
That's not bug detection. That's a race between the screenshot trigger and the network. And it makes visual regression worthless — the team stops trusting the suite.
The pattern
Wait for structural stability before capturing. Not just networkidle. Specifically:
- Skeleton elements gone. Watch the DOM for any element with
[data-skeleton],[aria-busy="true"], or a shimmer-effect class. Wait until none are present for two consecutive RAFs. - Spinners and indeterminate loaders settled. Same pattern with role-based selectors (
[role="progressbar"]). - Layout stable. Compute the bounding box of the main content container at frame N and N+1. Total shift < 1px in both dimensions.
- Bounded network idle. Network idle as a check, but capped at 5s so an analytics ping doesn't keep the spec waiting forever.
The escape hatch
Some pages are intentionally animated (a live chat counter, a price ticker). The helper takes an ignoreSelectors argument so the analyzer can know "this part will always be moving — capture anyway."
Result
Once shipped: flaky visual-regression runs dropped by ~50% with no other change. The team started trusting the suite, which is the whole game.
// helpers/wait-for-stable.ts export async function waitForStable(page, opts = {}) { const { ignoreSelectors = [], maxWaitMs = 10000, stableFrames = 2 } = opts; const start = Date.now(); // 1) wait for skeletons + loaders await page.waitForFunction(() => { const sels = ['[data-skeleton]', '[aria-busy="true"]', '.skeleton', '[role="progressbar"]']; return !sels.some(s => document.querySelector(s)); }, { timeout: maxWaitMs }); // 2) layout stability over N frames let stable = 0, lastRect = null; while (stable < stableFrames && Date.now() - start < maxWaitMs) { const rect = await page.evaluate(() => document.body.getBoundingClientRect().toJSON()); if (lastRect && Math.abs(rect.height - lastRect.height) < 1) stable++; else stable = 0; lastRect = rect; await page.waitForTimeout(16); } // 3) bounded network idle await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); }