ScreenshotNeo

BlogHow-to

How to Reduce Flaky Visual Regression Tests Caused by Network Requests

Make visual tests deterministic by controlling the responses behind a UI state, waiting for visible readiness, and keeping the rendering environment stable.

By the ScreenshotNeo team4 October 20269 min read

Flaky visual regression tests often capture a page while network-driven content is still changing, or use API data that differs between runs. Control the responses that determine the state under test, wait for the expected UI to appear, and capture in a consistent browser and rendering environment. A mocked response makes the screenshot test repeatable; it does not prove that the live service currently behaves the same way.

This guide shows how to stabilize those tests with Cypress and Playwright, how to choose between mocked and live data, and how to diagnose the failures that remain.

Why network requests make screenshots flaky

A screenshot records the page at the moment the capture happens. If an API response arrives late, changes between runs, or triggers UI updates after the screenshot, the resulting image can differ even when the frontend code has not changed. Cypress summarizes the issue: “Real API responses change over time, which makes screenshots change too.” Cypress documentation

Common causes include:

  • Variable response data: live records, timestamps, counts, or ordering change over time.
  • Variable timing: the same response arrives at different points relative to rendering and capture.
  • Unsettled UI: a request completes, but the application still needs to update state, render, or load dependent content.
  • Unstable rendering conditions: browser, operating system, viewport, fonts, or animations differ.
  • Uncontrolled third-party content: a widget or remote embed changes independently of your app.

A reliable approach

  1. Choose the state the screenshot should prove. Identify the specific request or requests that supply that state. Avoid intercepting unrelated traffic without a reason.
  2. Return a stable fixture or explicit mock response. Keep its contents representative of the state under test and consistent across runs.
  3. Reach the state through the test’s normal setup or user actions. Wait for the relevant request when useful, then assert that the expected UI is visible.
  4. Capture after the UI assertion succeeds. A finished request alone does not guarantee that application updates and rendering are complete.
  5. Stabilize the rendering setup. Keep browser, operating system, viewport, and fonts consistent where practical. Disable or settle animations, and mask only small regions that truly cannot be controlled.
  6. Keep separate integration coverage where needed. A visual test with fixtures checks that the frontend renders a known response. Use tests against the real service to check live integration behavior.

Do not use a fixed sleep as the only readiness signal. Nor should a generic network-idle condition stand in for an assertion about the intended UI: polling or long-lived requests may prevent idleness, and idleness does not itself establish the expected state.

Cypress: intercept, wait, assert, capture

Cypress supports intercepting a request and replying with fixture data. Alias the request, wait for it after the action that triggers it, assert the relevant content, and then take the visual snapshot. The precise snapshot command depends on the visual testing integration your project uses.

describe('items visual state', () => {
  it('renders a stable list', () => {
    cy.intercept('GET', '/api/items', {
      fixture: 'items-for-visual-test.json',
    }).as('getItems');

    cy.visit('/items');
    cy.wait('@getItems');

    // The request finished; verify the rendered state before capture.
    cy.get('[data-testid="item-list"]').should('be.visible');
    cy.contains('[data-testid="item-list"]', 'Example item').should('be.visible');

    // Replace with the snapshot command provided by your visual testing integration.
    cy.get('[data-testid="item-list"]').matchImageSnapshot();
  });
});

Save the fixture at cypress/fixtures/items-for-visual-test.json, matching the response shape your app expects. For example:

{
  "items": [
    { "id": "visual-1", "name": "Example item", "status": "ready" }
  ]
}

If the app loads only after a user action, install the intercept before that action, trigger it, wait on the alias, and assert the resulting view before the snapshot. Cypress recommends checking that the intended UI state is present before taking the snapshot; its visual testing guide also discusses stable rendering conditions and animation limitations. Cypress visual testing guidance

Keep the interception narrow

Match the method and endpoint that actually supply the page state. Broad wildcard interception can unintentionally stub analytics, assets, or other requests that the test needs. If the request includes query parameters, use a route matcher that verifies the relevant parameters so the fixture is returned only for the intended state.

Playwright: route requests and assert the rendered state

Playwright supports request routing through page.route() and browserContext.route(). This example fulfills the application request with deterministic JSON, waits for that request, checks visible UI, and then captures a screenshot:

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

test('items list renders a stable state', async ({ page }) => {
  await page.route('**/api/items', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        items: [{ id: 'visual-1', name: 'Example item', status: 'ready' }],
      }),
    });
  });

  const responsePromise = page.waitForResponse(response =>
    response.url().includes('/api/items') && response.request().method() === 'GET'
  );

  await page.goto('/items');
  await responsePromise;

  await expect(page.getByTestId('item-list')).toBeVisible();
  await expect(page.getByTestId('item-list')).toContainText('Example item');
  await expect(page.getByTestId('item-list')).toHaveScreenshot('items-list.png');
});

In an existing project, preserve its configured base URL, test runner, and snapshot workflow. If you need the route handler to apply across pages in a context, register it with browserContext.route(); a page-specific route is suitable when the request belongs to one page.

Service Workers and missing routes

If native routing does not see requests that the page makes, check whether a Service Worker is handling them. Playwright’s network documentation recommends blocking Service Workers when native route handling is needed and network events are not visible. For a Playwright Test project, configure the context accordingly:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    serviceWorkers: 'block',
  },
});

Apply this setting when it fits the test’s purpose; a test specifically validating Service Worker behavior needs a different setup. Playwright network documentation

Choose mocks or live requests based on the test question

Approach What it verifies Trade-off
Fixture or mocked response Whether the frontend renders a known state correctly. Stable and controlled, but does not establish what the live backend returns.
Seeded test backend Frontend behavior with realistic backend integration and known data. Exercises more of the stack, while requiring reliable setup and cleanup of test data.
Uncontrolled live data Some real end-to-end behavior against current service responses. Response content, availability, and timing can vary and make screenshot comparisons noisy.

These approaches can coexist. Use fixtures for visual states where the question is “does this known state render correctly?” Keep separate integration tests for behavior that depends on the actual service. A visual testing or review service can help with snapshot review and diagnostics, but it is optional; it does not remove the need to control the data and readiness conditions that determine the screenshot.

Animations, dynamic regions, and capture scope

  • Disable or settle animations before capture. Cypress notes that action-level animation waiting does not stop unrelated animations elsewhere on the page from being captured mid-frame.
  • Control dynamic content when it belongs to the test. If a changing value is part of the expected state, use a stable fixture or seeded data.
  • Mask the smallest unavoidable region. For genuinely uncontrolled third-party content, mask only that region rather than loosening the difference threshold across the whole screenshot.
  • Prefer a focused target when it answers the test question. A component screenshot can avoid unrelated page regions, while a full-page capture is appropriate when page composition or layout is what you need to verify.

Keep masks narrow and intentional: masking a large area can hide a real regression. Cypress’s guidance on snapshot timing, meaningful state, and animations is available in its visual testing documentation.

Debug failures with evidence from the same run

When a diff remains, inspect the screenshot alongside the DOM state, request outcome, console output, and timing from that run. Ask:

  • Did the intended request happen, and did the route handler match it?
  • Did the response contain the fixture or expected seeded data?
  • Was the expected content visible when capture occurred?
  • Did a later update replace or reorder the content after the assertion?
  • Did an animation, font load, viewport change, or third-party widget affect pixels?

Chromatic documents unstable-test diagnostics that include network requests, console logs, DOM snapshots, and snapshot metadata. Such traces can help identify timing and state mismatches; they do not make deterministic response setup unnecessary. Chromatic documentation

Performance, reliability, and cost

Fixtures usually reduce dependence on external service availability and variable response time, which can make a visual test run more predictable. They also narrow what the test proves. A seeded backend retains more integration realism but introduces setup, data lifecycle, and service availability concerns. Choose based on the assertion, and keep both kinds of coverage when both rendering and live integration matter.

Prefer targeted routes over broad interception: they are easier to understand and less likely to hide unexpected calls. Keep fixtures small enough to represent the state, but complete enough for the component’s actual rendering logic. Avoid compensating for unstable inputs by globally increasing screenshot tolerance; that can make genuine visual changes harder to detect.

Common failures and fixes

Symptom Likely cause Fix
The screenshot sometimes shows a loading state. Capture happens before the response or UI update completes. Wait for the relevant request where useful, then assert the loaded UI before capture.
The request wait times out. The route was registered after the request, the URL or method matcher is wrong, or the action did not trigger the request. Register routing before navigation or interaction; verify the exact method, path, query, and triggering action.
Playwright’s handler does not run. A Service Worker may be handling the request, or the route pattern does not match. Check the route pattern and consider serviceWorkers: 'block' when native routing needs to observe the request.
The request wait passes, but the screenshot still differs. The app updates after response completion, or another source such as animation or a widget remains variable. Add an assertion for the final visible state; inspect later requests and DOM changes; settle or narrowly mask uncontrolled content.
The fixture is returned but the page errors or renders empty. The fixture shape, status, content type, or fields do not match the frontend’s expectations. Match the real response schema and required fields; inspect the application error and network response.
Diffs appear across machines despite stable data. Browser, operating system, viewport, or fonts differ. Run comparisons in a consistent rendering environment and pin the viewport and relevant browser setup.
Only a third-party embed changes. The content is outside the test’s control. Stub it if it is relevant and within scope; otherwise mask the smallest region that contains the volatility.
A page never reaches network idle. Polling or long-lived connections keep activity ongoing. Wait for the relevant response and assert the intended UI instead of treating network idle as universal readiness.

Or skip the browser setup

If you need a clean capture of a URL without building and maintaining screenshot browser setup, ScreenshotNeo provides a screenshot API and MCP server. The API accepts a URL and returns an image or PDF; its documented options include CSS selector capture, full-page capture, custom waits, and other capture settings. See the ScreenshotNeo API documentation for request options.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
  • Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
  • Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers report the page verdict and billing status.
  • An MCP server lets AI agents use screenshot, page information, and PDF capture tools.
  • 1,000 screenshots per month are free with no card; paid plans start at $5 for 3,000 screenshots.

ScreenshotNeo is useful when the goal is capturing a URL without managing browser infrastructure. For a regression test that must render a specific known API state, keep the framework-native fixture approach above. Learn more at ScreenshotNeo, or sign up for 1,000 free screenshots a month with no card.

FAQ

Should every request in a visual test be mocked?

No. Stub the requests that determine the state being compared. Unrelated requests can remain untouched unless they introduce visible instability or affect the target behavior.

Does waiting for a response guarantee the screenshot is ready?

No. The app may still need to process the response and update the DOM. Assert the visible state you intend to capture.

Can a visual test with fixtures replace integration tests?

No. It verifies frontend rendering against the supplied response. Keep coverage that exercises the real backend behavior your product depends on.

Should I mask every changing value?

Only when the source is genuinely outside the test’s control and the exact value is not part of the visual behavior being tested. Prefer controlling relevant data.