Lazy-Loaded Content Tests That Don't Get Stuck on Placeholders
By Markus Gasser · September 13, 2026
A practical guide to testing lazy-loaded content, placeholder swaps, and viewport-triggered renders with browser automation, including what to assert before scroll, after scroll, and after network idle.
Lazy-loaded content fails in a very specific way: the page looks present, but the thing you need to verify is still a placeholder, still offscreen, or still waiting for the browser to decide it matters. That makes naive assertions brittle, especially when a component relies on loading="lazy", IntersectionObserver, virtualization, or scroll-driven fetches.
The practical goal is not to prove that the page scrolled. It is to prove that the right content eventually became visible, that the placeholder was replaced, and that the page did not regress into an empty state or a never-ending spinner.
The most useful lazy-loading test is usually a three-stage test: assert the pre-scroll state, trigger the viewport change, then assert the post-scroll state after the relevant loading signal has settled.
What is actually being tested?
These cases are often grouped together, but they are not the same.
loading="lazy"is a browser-level hint for images and iframes. The browser may delay fetching until the resource is near the viewport.IntersectionObserveris an app-level signal that a component has entered a threshold in the viewport. The app may then fetch data, swap placeholders, or mount a subtree.- Scroll-triggered UI can mean infinite lists, reveal-on-scroll sections, or components that only render after the user moves the page.
That distinction matters because the failure mode changes:
- browser lazy loading can fail because the resource URL is wrong, the image never starts loading, or the test never scrolled far enough
- intersection-based UI can fail because the observer threshold never fires, the root container is wrong, or an animation delays the final state
- virtualized content can look missing even when it is working, because the DOM only contains what is near the viewport
For reference, see the browser behavior and API shape in MDN on loading="lazy" and MDN on IntersectionObserver.
A reliable assertion sequence
For browser automation, I recommend a sequence that answers three separate questions.
1) Before scroll, what should be true?
Before triggering any scroll, verify the placeholder state, not the final content state.
Good pre-scroll checks include:
- placeholder skeleton exists
- element is in the DOM, but not yet visible
srcor data payload is not yet applied, if your UI swaps those later- the lazy target is below the fold, if that is part of the contract
Example in Playwright:
import { test, expect } from '@playwright/test';
test('lazy section loads after scroll', async ({ page }) => {
await page.goto('/catalog');
const card = page.getByTestId('product-card-24');
await expect(card.getByTestId('placeholder')).toBeVisible();
await expect(card.getByRole('img')).toHaveCount(0);
});
This is a useful starting point because it proves the page has not prematurely rendered the final state.
2) After scroll, did the trigger actually happen?
Do not jump straight to the final assertion. First make sure the viewport change happened in a way that could satisfy the lazy-loading condition.
Useful techniques:
- scroll the specific container, not just
window, if the list lives inside a panel - use
locator.scrollIntoViewIfNeeded()when the target is a specific element - wait for the app to attach the final node, not just for a generic timeout
- if the component is behind an
IntersectionObserver, verify the element reaches the intended viewport threshold
Example:
await card.scrollIntoViewIfNeeded();
await expect(card.getByRole('img')).toHaveAttribute('src', /product-24/);
If the page uses a scroll container, target that container explicitly. A frequent testing mistake is scrolling the document while the app is observing a nested list.
3) After network idle, did the content settle into the expected state?
A post-scroll wait is useful, but only if you know what it means in your app.
Use it when the UI fetches a resource after viewport entry and then swaps the placeholder for final content. Avoid treating network idle as a universal success signal, because some pages keep background requests alive and never become truly idle.
A safer pattern is:
- wait for the expected response, if the request is deterministic
- then assert the final visible state
- then assert that the placeholder disappeared
Example:
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/products/24') && resp.ok()),
card.scrollIntoViewIfNeeded()
]);
await expect(card.getByTestId('placeholder')).toBeHidden();
await expect(card.getByRole('img')).toBeVisible();
How to separate a real loading bug from a delay
This is where most flaky tests waste time. A missing final state does not automatically mean a bug in lazy loading.
Likely a real bug if:
- the trigger element never intersects the viewport even after deliberate scrolling
- the expected request never appears in the network log
- the DOM node stays in placeholder state well after the fetch resolves
- the image response returns 200, but the
imgnever updates or never becomes visible
Likely a timing or rendering delay if:
- the request exists and returns successfully, but an animation keeps the element at low opacity
- virtualization unmounts the row briefly before re-rendering it
- a transition delays the final style, even though the node is already present
- the app uses progressive loading, so a low-resolution placeholder is expected first
A simple rule helps:
If the network request finished and the final DOM node exists, look at rendering and visibility. If the request never fired, look at the scroll trigger, observer root, and viewport conditions.
What to log when the element never appears
When lazy-loaded content never shows up, the fastest path is usually better logging, not a longer timeout.
Log these facts in the test failure path:
- scroll container selector and scroll position
- current viewport size
- whether the target is in the DOM before and after scroll
- whether any matching request was observed
- placeholder text or test id still present
- whether the target is inside a virtualized list
A small helper can capture enough state to make the failure readable:
async function dumpLazyState(page, selector) {
return await page.evaluate((sel) => {
const el = document.querySelector(sel);
return {
exists: Boolean(el),
text: el?.textContent?.slice(0, 120) ?? null,
rect: el ? el.getBoundingClientRect().toJSON() : null,
scrollY: window.scrollY,
viewport: { width: window.innerWidth, height: window.innerHeight }
};
}, selector);
}
If your app uses a custom scroll container, capture scrollTop on that element too. Without that, a test log can incorrectly suggest that the page never moved.
Special cases that need different handling
Virtualized lists
Virtualization is not lazy loading, but it can look the same from a test’s perspective. A row may not exist in the DOM until you scroll near it.
For virtualized content:
- assert the row is absent before scroll if that is expected
- scroll the list container in steps if one jump does not trigger render
- wait for the item count or a stable row locator after the render
- avoid assertions that depend on offscreen DOM nodes
If the list recycles elements, a test should verify the visible content by text, role, or stable test ids, not by assuming a persistent node identity.
Skeleton screens and animated placeholders
A skeleton is not a failure by itself. The test should fail only when the placeholder never gets replaced.
Watch for these patterns:
- CSS animation continues, but the final content is already present, so visibility assertions are the wrong signal
- placeholder and final content overlap briefly, which can make text assertions racey
- opacity transitions make an element technically present but not interactable yet
In that case, prefer explicit conditions such as toBeHidden(), toHaveAttribute(), or a specific text change rather than raw sleep calls.
Infinite scroll and append-only feeds
For feeds that load more items at the bottom, the important assertion is usually not the first reveal. It is whether the next batch appended and whether the sentinel element still behaves correctly.
Test the following:
- initial batch count
- append after scrolling near the bottom
- no duplicate items
- loading indicator disappears
- the sentinel or trigger is still present for the next batch
A practical decision framework
When you write or fix a lazy-loading test, decide which signal is the source of truth.
| Page behavior | Primary assertion | Secondary check |
|---|---|---|
| Browser lazy image | final src or rendered image visible |
placeholder disappears |
| IntersectionObserver render | node appears after scroll | request or callback fired |
| Virtualized list | target row becomes visible | row count changes in viewport |
| Infinite scroll | new items appended | loader disappears |
| Animated placeholder | final content visible, not just present | placeholder hidden |
If you cannot name the source of truth, the test is probably asserting too early or on the wrong layer.
Common anti-patterns
Using a fixed sleep
A waitForTimeout(5000) only hides uncertainty. It makes the test slower on fast paths and still flaky on slow paths.
Scrolling without checking the container
Many pages have nested scroll regions. If the lazy target is inside a scrollable panel, scrolling window may do nothing useful.
Asserting only on visibility
Visibility alone can lie. A placeholder can be visible, a shimmer can be visible, or an overlay can block the actual content.
Waiting for the wrong network signal
If the page prefetches unrelated assets, networkidle can become noisy. If the page keeps analytics or long-polling open, it may never settle.
A short Playwright pattern that stays readable
This pattern keeps the test focused on behavior instead of timing.
await page.goto('/feed');
const item = page.getByTestId('feed-item-42');
await expect(item.getByTestId('skeleton')).toBeVisible();
await item.scrollIntoViewIfNeeded();
await expect(item.getByRole('img')).toHaveAttribute('src', /item-42/);
await expect(item.getByTestId('skeleton')).toBeHidden();
If the app is request-driven, add a response wait. If the app is purely observer-driven, use the scroll and DOM change as your main signal. Do not add waits that do not correspond to a real state change.
What I would log first in a failing build
When a lazy-loaded target disappears or never appears, I would capture, in this order:
- the scroll target and current scroll offset
- whether the target exists before and after the scroll
- whether the network request fired
- whether the placeholder was replaced
- whether virtualization is active
That sequence narrows the bug quickly because each item maps to a different failure class.
- no target in DOM, the selector or fixture is wrong
- target exists but never triggers, the viewport or observer setup is wrong
- request fires but content stays blank, the render or data binding is wrong
- final node appears but is hidden, it is a visibility or animation issue
- only some rows appear, virtualization or list height is the issue
Bottom line
To test lazy-loaded images, scroll-triggered UI, and observer-based content without chasing empty placeholders, build tests around state transitions, not delays. Assert the placeholder first, trigger the actual viewport condition, then verify the final content and the disappearance of the temporary state. When something fails, decide whether the problem is in the trigger, the network, the render, or the visibility layer before you change the timeout.
That approach is more stable than waiting longer, and it produces failures that a developer can actually debug.
FAQ
Should I always use networkidle for lazy-loaded content?
No. Use it only when the page has a clear, short-lived request pattern. Many apps keep background requests open, which makes networkidle unreliable.
How do I test loading="lazy" if the browser decides when to fetch?
Scroll the element near the viewport and assert the final image state after the browser has had a chance to fetch it. Pair that with a request or DOM assertion so the test is not guessing.
Why does the placeholder still show even though the request finished?
That usually points to a render, binding, or visibility problem, not a network problem. Check whether the final node exists, whether CSS is hiding it, and whether an animation is delaying the swap.
How do I test an IntersectionObserver component?
Scroll the observed element into the threshold area, then assert the state change that the observer is supposed to trigger. If nothing happens, inspect the observer root, threshold, and container scroll position.
What is the best locator strategy for virtualized lists?
Use stable row identifiers, roles, or test ids tied to visible content. Avoid relying on DOM nodes that can be recycled when the list rerenders.