To screenshot observer-driven pages reliably, either drive the page so every observer fires (scroll through it, then wait for images, fonts and animations to settle) or put the page in a test mode that loads eagerly and disables motion — and never take a full-page screenshot straight after navigation.

Problem / Scenario Context

A marketing site adds visual regression tests with Playwright's full-page screenshots. Every run differs: below-the-fold images are placeholders in some runs and loaded in others, reveal animations are captured half-faded, and an infinite list has a different number of items depending on how quickly the screenshot scrolled. The team raises the diff threshold until the tests pass — at which point they no longer catch real regressions.

Observer-driven content is non-deterministic in screenshots because a full-page capture does not scroll the page the way a user does. The Testing Observers in JSDOM & Real Browsers topic covers functional tests; this page covers pixels.

Mechanics Explanation

A full-page screenshot is taken by resizing the capture area to the document height (or by stitching viewport-sized captures, depending on the tool and browser). In both cases, the page is not scrolled in the normal way:

  • Lazy images whose observers use the implicit root may or may not see themselves as intersecting, depending on whether the tool enlarges the viewport. Native loading="lazy" images may not start loading at all.
  • Reveal animations start when observers fire and take hundreds of milliseconds; a capture moments later sees them mid-transition.
  • Infinite scroll sentinels may fire once, several times, or not at all depending on the effective viewport size.
  • Network timing decides whether an image that started loading has finished.

So the captured state is a race between the tool, the observers and the network. The fix is to remove the race: either make everything happen and wait for it, or stop it from being conditional.

Two Ways to Make Screenshots DeterministicTwo columns. Driving the page means scrolling through it in steps so every observer fires, then waiting for network idle, image decoding and animations to finish before capturing; it tests the real behaviour. Test mode means a flag that makes observers load eagerly and disables transitions, so the page renders its final state immediately; it is faster and fully deterministic but bypasses the observer path.Drive the pageScroll through in viewport-sized stepsWait for network idle, image decode, animationsTests the real observer pathSlower; still sensitive to timingTest modeFlag makes lazy content load eagerlyTransitions and animations disabledFinal state renders immediatelyFast and deterministic; bypasses observers

Comparison Table: Sources of Flakiness and Remedies

Flaky element Cause Remedy when driving Remedy in test mode
Placeholder vs loaded image observer may not fire scroll, then img.decode() all load eagerly
Half-faded reveal captured mid-transition wait for getAnimations() to finish disable transitions
Item count in infinite list sentinel fires variably cap pages; scroll to a fixed count render a fixed number
Web font swap font arrives late document.fonts.ready preload or bundle fonts
Carousel position autoplay timer pause autoplay disable autoplay
Relative timestamps "3 minutes ago" freeze the clock freeze the clock

Minimal Reproducible Example

TypeScript
import { test, expect } from '@playwright/test';

test('home page', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot({ fullPage: true });   // races lazy loading and reveals
});

Run it five times: the diffs show different images loaded and reveals at different opacities.

Production-Safe Solution

Provide both strategies: a helper that drives the page for tests that must exercise observers, and a test-mode flag the site honours for pure layout screenshots.

TypeScript
// screenshot-helpers.ts
import type { Page } from '@playwright/test';

/** Scroll through the page so every observer fires, then wait for everything to settle. */
export async function settleByScrolling(page: Page): Promise<void> {
  await page.evaluate(async () => {
    const step = innerHeight * 0.8;
    for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
      scrollTo(0, y);
      await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
    }
    scrollTo(0, 0);
  });
  await page.waitForLoadState('networkidle');
  await page.evaluate(async () => {
    await document.fonts.ready;
    await Promise.all([...document.images].map((img) => img.decode().catch(() => {})));
    await Promise.all(document.getAnimations().map((a) => a.finished.catch(() => {})));
  });
}
TypeScript
// In the site: honour a test-mode flag before any observer is created.
const testMode = new URLSearchParams(location.search).has('visual-test');
if (testMode) document.documentElement.classList.add('visual-test');

export function lazyLoad(img: HTMLImageElement): void {
  if (testMode) { img.src = img.dataset.src!; return; }       // eager in test mode
  lazyObserver.observe(img);
}

declare const lazyObserver: IntersectionObserver;
CSS
.visual-test *, .visual-test *::before, .visual-test *::after {
  transition: none !important;
  animation: none !important;
}
.visual-test .reveal { opacity: 1 !important; transform: none !important; }
TypeScript
// The tests
test('home page layout (test mode)', async ({ page }) => {
  await page.goto('/?visual-test');
  await page.evaluate(() => document.fonts.ready);
  await expect(page).toHaveScreenshot({ fullPage: true });
});

test('home page after real scrolling', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/');
  await settleByScrolling(page);
  await expect(page).toHaveScreenshot({ fullPage: true, maxDiffPixelRatio: 0.001 });
});

Emulating reduced motion in the driven test is a useful shortcut if the site honours it correctly — reveals then appear without movement, so there is nothing to wait for — and it doubles as a check that the reduced-motion path renders complete content, as covered in respecting prefers-reduced-motion in scroll reveals.

Settling a Page Before CaptureFive steps. Scroll through the page in steps of eighty percent of the viewport, waiting two animation frames at each step so observers fire. Scroll back to the top. Wait for network idle so triggered loads complete. Wait for fonts, image decoding and running animations to finish. Capture the screenshot.1Scroll in steps80% of the viewport at a time; two rAFs per step.2Return to topSo fixed and sticky elements are in their default state.3Network idleEverything the observers requested has loaded.4Fonts, decode, animationsdocument.fonts.ready, img.decode(), getAnimations().finished.5CaptureDeterministic full-page screenshot.

Bounding Infinite Content

Infinite lists are the hardest case, because "scroll to the bottom" never ends. Bound them explicitly:

  • Cap pages in test mode. A query flag that limits the feed to one or two pages gives a fixed height.
  • Stop at a known item count when driving: scroll until list.children.length >= 40, then stop and capture only the list's container with an element screenshot rather than the full page.
  • Stub the data. Serve the list's API from a fixture with Playwright's page.route, so every run has identical items.
TypeScript
await page.route('**/api/feed*', (route) => route.fulfill({ path: 'fixtures/feed-page-1.json' }));

Combining a stubbed API with a page cap makes infinite scroll as deterministic as a static page. The underlying mechanics are in infinite scroll & pagination.

Making an Infinite Feed Screenshot-StableThree boxes. The feed's API is stubbed with a fixture so the items are identical on every run. A test-mode page cap stops the sentinel from loading more than a fixed number of pages. An element screenshot of the list container, rather than the full page, captures exactly those items.Stub the APIidentical items every runCap pagessentinel stops after N pagesElement screenshotthe list container only

Verification Steps

  • Run each visual test ten times locally with --repeat-each=10 and confirm zero diffs.
  • Break a lazy image path on purpose and confirm the driven test catches it while test mode does not — then decide which tests need which mode.
  • Run under CPU throttling in CI to shake out remaining timing assumptions.
  • Check that test mode is inert in production — the flag must only change behaviour when present.
  • Review diff thresholds; with deterministic pages they can be near zero.

Common Mistakes to Avoid

  • Raising the diff threshold to hide flakiness. It stops catching real regressions.
  • Full-page screenshots straight after goto. Observers have not fired yet.
  • Waiting with fixed sleeps. They are slow and still race on slow CI machines.
  • Letting test mode leak into production behaviour. Gate it on an explicit flag.

FAQ

Why do full-page screenshots miss lazy-loaded images?

Because the page is not scrolled the way a user scrolls it. Depending on the tool, the observers may never see the lower content as intersecting, so the images never start loading before the capture.

Is it cheating to load everything eagerly in tests?

For layout screenshots, no: the goal is to catch visual regressions in the final state, and eager loading produces exactly that state deterministically. Keep at least one driven test so the observer path itself is covered.

How do I wait for CSS transitions to finish?

document.getAnimations() includes running CSS transitions and animations. Awaiting each animation's finished promise waits until they are all done.

Does emulating reduced motion help?

Yes, if the site honours it: reveals appear without transitions, removing a whole class of timing issues. It also verifies the reduced-motion path renders complete content.

Do native lazy images load during a full-page screenshot?

Not reliably. Browsers decide when to load loading="lazy" images based on their distance from the viewport, and a full-page capture may not change that distance the way scrolling does. Scroll through the page first, or set loading to eager in test mode.

How do I keep sticky headers from appearing mid-page in stitched screenshots?

Scroll back to the top before capturing, and if the tool stitches viewport captures, hide or unstick fixed and sticky elements in test mode. Tools that resize the capture area instead of stitching avoid the problem entirely.

Should I screenshot the full page or components?

Both have a place. Component screenshots in isolation are more stable and pinpoint changes; a few full-page screenshots catch integration and layout issues between components.


↑ Back to Testing Observers in JSDOM & Real Browsers