Cypress Visual Testing for Lazy-Loaded Pages
Make Cypress visual snapshots wait for lazy content to load and settle. Includes runnable test patterns, stability checks, troubleshooting, and a screenshot API option.
To take a reliable Cypress visual snapshot of lazy-loaded content, deliberately trigger the load by scrolling the relevant section into view, wait for its specific network request or an application readiness signal, assert that the expected content is present, and only then capture the snapshot. A DOM query alone does not scroll an element into view, and cy.visit() waiting for the browser load event does not mean later asynchronous work is finished. Cypress recommends taking a snapshot only after confirming the page is done changing.
1. The reliable sequence
A visual snapshot records pixels at one moment. If a deferred request, image, font, animation, or layout shift is still in progress, the captured frame can be incomplete or inconsistent. Build the test around the exact UI state you want to compare:
- Register an intercept for the relevant API request before visiting the page.
- Visit the page and set a consistent viewport.
- Scroll the lazy section or its trigger into view.
- Wait for the specific request, if one drives the content.
- Use retryable assertions to confirm the expected content and settled state.
- Run the visual snapshot command immediately after those checks.
The request URL, selector, expected item count, and snapshot command depend on the application and visual-testing plugin. The Cypress example below shows the pattern; replace the illustrative selector and endpoint with the ones your app actually uses.
2. Runnable Cypress example
This test assumes the project has Cypress installed, a /catalog route, a deferred section with the given test ID, and an API request matching /api/products. It uses Cypress’s built-in screenshot command, so the file is runnable without a visual-diff plugin. To compare against managed visual baselines, replace the final screenshot call with the snapshot command supported by your chosen tool.
describe('catalog lazy content', () => {
it('captures the product section after it has loaded', () => {
cy.intercept('GET', '/api/products*', {
fixture: 'products.json',
}).as('products')
cy.viewport(1280, 900)
cy.visit('/catalog')
cy.get('[data-testid="deferred-section"]').scrollIntoView()
cy.wait('@products')
cy.get('[data-testid="product-card"]').should('have.length', 3)
cy.get('[data-testid="deferred-section"]')
.should('be.visible')
.and('have.attr', 'data-load-state', 'ready')
// Built-in Cypress screenshot. A visual testing plugin can use its
// own snapshot command here, after the same readiness assertions.
cy.get('[data-testid="deferred-section"]').screenshot('catalog-products')
})
})
For the example’s final assertion, the application must set data-load-state="ready". If it does not expose a settled-state attribute, remove that assertion and use another meaningful, observable condition, such as the disappearance of a loading indicator. Keep the snapshot after the last assertion that proves the target state.
Using a visual-diff plugin
Cypress supports both open-source screenshot comparison plugins and hosted visual-testing services. They differ in where images are rendered and compared, supported browsers and viewport sizes, baseline review, and masking workflows. Check a plugin’s current Cypress compatibility in the Cypress plugin directory and follow that plugin’s documented snapshot command. Keep the intercepts, scroll trigger, and readiness assertions from the example; only the final capture command should change.
3. Triggering lazy loading correctly
Lazy loading can begin when an element approaches the viewport, when a user clicks or expands a section, or when application code schedules a request. Make the trigger explicit in the test. A query such as cy.get('[data-testid="deferred-section"]') finds the element but does not, by itself, scroll it into view.
Viewport-triggered content
cy.get('[data-testid="deferred-section"]').scrollIntoView()
cy.wait('@deferredContent')
cy.get('[data-testid="deferred-section"] [data-testid="result"]')
.should('have.length.greaterThan', 0)
Click- or expansion-triggered content
cy.intercept('GET', '/api/details/*').as('details')
cy.get('[data-testid="show-details"]').click()
cy.wait('@details')
cy.get('[data-testid="details-panel"]').should('be.visible')
cy.get('[data-testid="details-panel"]').screenshot('details-panel')
Content without a dedicated request
Some pages load from an existing data store or perform work without a request you can reliably intercept. Assert an application-level signal instead: a loading indicator disappears, a known number of items appears, or the app marks the region ready. Cypress retries assertions until they pass or time out, which is more robust than guessing a delay.
4. Waiting for the whole visual state
Waiting for one network alias proves that request completed; it does not prove every part of the page is visually settled. Images may still be decoding, fonts may change text width, and unrelated animations may continue. Use the narrowest signal that covers the content in the snapshot.
- API data: wait for the specific intercepted request and assert the rendered result.
- Images that affect layout: assert the relevant image is complete or use an application readiness state that is set only after the images needed for the capture are ready.
- Loading state: assert the spinner or skeleton is gone and the final content is visible.
- Fonts and layout: use the same browser and environment for baseline and comparison; avoid capturing while the page is changing.
- Animations: disable or complete application animations in the test environment where practical. Cypress action options such as
waitForAnimationsapply to action commands; they do not freeze every animation on the page for a screenshot.
There is no universal lazy-image readiness API prescribed for every app. Tie the test to your app’s actual render lifecycle and the particular assets that matter to the captured region.
5. Make visual results repeatable
Reduce differences unrelated to code changes so a visual failure points to a meaningful UI change.
- Set the same viewport for baseline creation and comparison, using
cy.viewport(width, height). - Keep the browser, operating system, fonts, and rendering environment consistent.
- Stub changing API data with fixtures or controlled intercept responses.
- Wait for a functional assertion immediately before the snapshot.
- Disable animations or time-dependent UI in a test configuration where feasible.
- Mask only small regions that cannot be controlled, such as a genuinely time-varying third-party widget. Avoid loosening the comparison threshold for the entire page to hide a local source of noise.
- Prefer a component or element capture for focused regressions. Use a full-page capture when the page-level layout itself is the behavior under test.
Full-page snapshots include more unrelated content and can create more changes to review. Choose a capture scope that matches the risk being checked.
6. Cypress timeouts and waiting options
Cypress has several timeout settings, and they cover different stages. The documented default for pageLoadTimeout is 60,000 milliseconds; it governs waiting for the page load event, not all deferred API calls or application rendering. Cypress does not automatically wait for every XHR or Ajax request after cy.visit().
| Need | Use | What it establishes |
|---|---|---|
| Wait for page navigation/load | pageLoadTimeout |
How long Cypress waits for the browser page load event during navigation. |
| Wait for a specific API call | cy.intercept() and cy.wait('@alias') |
Completion of the request matched by that intercept. |
| Wait for a rendered condition | Retryable cy.get(...).should(...) |
The asserted DOM condition eventually becomes true, subject to the command timeout. |
| Handle an unusually slow request or assertion | Per-command timeout or the relevant Cypress configuration |
More time for that operation; it does not make the page ready by itself. |
Increase a timeout only when the operation is legitimately slow and the test has a real completion condition. A larger timeout cannot repair a missing trigger, wrong intercept, or assertion that checks the wrong state. See Cypress configuration documentation for current settings and their distinct purposes.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Content below the fold is missing | The test queried the node but never triggered its viewport-based loader. | Scroll the relevant section into view, then wait for its request or readiness signal. |
| Snapshot sometimes shows a spinner | The capture runs before asynchronous content has settled. | Wait for the specific request and assert final content or spinner disappearance before capture. |
cy.wait('@alias') times out |
The intercept was registered too late, its URL or method does not match, or scrolling did not cause the request. | Register the intercept before cy.visit(), verify the request matcher, and confirm the intended trigger occurs. |
| Request completes but the snapshot is still incomplete | The response finished, but rendering, image decode, another request, or layout work remains. | Add a relevant UI assertion or wait for the app’s settled signal; identify other requests or assets that affect the captured region. |
| Snapshots differ between runs without a code change | Dynamic data, timing, fonts, viewport, animation, or third-party content differs. | Fix test data and environment, stabilize animations, and narrowly mask only uncontrollable areas. |
| A visual plugin command is unknown or unavailable | The plugin is not installed or its API differs from the example. | Install and configure the selected plugin per its documentation; retain the Cypress readiness sequence and replace only the capture command. |
Increasing pageLoadTimeout changes nothing |
The problem occurs after page load while asynchronous content is still loading. | Wait for the relevant intercepted request or application condition rather than the navigation event. |
8. Choosing a visual testing workflow
Cypress documentation describes local screenshot comparison plugins and hosted visual-testing services. Compare tools by where rendering and comparison run, capture scope, browser and viewport coverage, baseline review and masking, data handling, and compatibility with your Cypress version. Cypress’s documentation lists Chromatic, Happo, LambdaTest SmartUI, Percy, Sauce Labs Visual, SmartBear VisualTest, and Wopee.io. Those descriptions reflect vendor and documentation claims, not independent comparative testing. Confirm current capabilities and compatibility directly with each provider and the Cypress plugin directory.
For screenshots of live web pages outside a Cypress visual-diff workflow, ScreenshotNeo is a website screenshot API and MCP server. It fits when a developer or AI agent needs to capture a URL as an image or PDF, with consent banners, newsletter popups, and chat widgets removed before the shot. It reports page verdict and billing status in response headers, and charges only for clean shots.
9. Or skip the browser setup
If the goal is to capture a page rather than compare a Cypress baseline, ScreenshotNeo takes a URL in one request. See the ScreenshotNeo API documentation.
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}`)
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`)
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()))
ScreenshotNeo removes cookie banners, popups, and chat widgets before the shot. Bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Sign up for 1,000 free screenshots a month, with no card.
10. FAQ
Does cy.visit() wait for lazy-loaded content?
No. It waits for the browser’s page load event, not every later request or viewport-triggered update.
Will cy.get() scroll an offscreen element into view?
No. Call scrollIntoView() when scrolling is the trigger for loading.
Is a fixed cy.wait(2000) a good solution?
Usually not. Prefer the matching request alias or a retryable assertion that proves the required UI state.
Should every visual test capture the whole page?
No. Capture the smallest scope that covers the regression you need to detect; use full-page captures for page-level layout checks.
Can I use ScreenshotNeo to compare Cypress baselines?
ScreenshotNeo is a screenshot API and MCP server. The Cypress baseline and diff workflow is handled by a visual-testing plugin or service; the API is useful when you need a URL capture without setting up browser automation.


