networkidle2 vs networkidle0: The Difference That Breaks Your Screenshots
networkidle2 vs networkidle0: The Difference That Breaks Your Screenshots
Taking screenshots with Playwright or Puppeteer, you'll hit this choice almost immediately: wait until networkidle0 or networkidle2? It seems minor. It isn't.
What these actually mean
networkidle0 — no network connections for 500ms. Zero. The page has to go completely silent before the screenshot fires.
networkidle2 — no more than 2 concurrent network connections for 500ms. A looser threshold that allows a couple of persistent connections to stay open.
The practical difference is enormous.
Where networkidle0 breaks things
Modern SPAs, dashboards, and analytics pages keep long-lived connections open. WebSockets for real-time updates. Server-Sent Events for live feeds. Background health-check pings. These connections never close — they're designed not to.
Tell Puppeteer to wait for networkidle0 on a page with an open WebSocket and you'll wait until the configured timeout kills the job. Default is 30 seconds. You've just burned half a minute on a page that loaded fine in 400ms.
Where networkidle2 breaks things
Two connections sounds generous, but lazy-loaded images and infinite scroll components often spawn 3-4 requests in quick succession. With networkidle2, the screenshot might fire during a batch of image fetches — capturing the skeleton loader instead of the actual content.
Payment pages and checkout flows are particularly bad here. A Stripe embed, a fraud detection script, and a tracking pixel can all fire in parallel. networkidle2 sees 2 connections at rest and triggers. The screenshot catches the page mid-initialization.
The approach that actually works
Forget waiting for the network to go idle. Wait for the content you actually care about.
await page.waitForSelector('[data-testid="main-content"]', { visible: true });
await page.waitForFunction(() => document.fonts.ready);
This waits for a specific element to appear and fonts to finish loading. No dependency on network state at all. Faster and more predictable.
If you don't control the page markup (third-party sites, public URLs), the fallback approach is to combine networkidle2 with a fixed delay:
await page.goto(url, { waitUntil: 'networkidle2' });
await page.waitForTimeout(800);
Not elegant, but it catches the cases where networkidle2 fires too early.
The timeout problem nobody talks about
Both idle strategies share a hidden failure mode: network requests that stall, not fail. A third-party analytics script that hangs indefinitely. An A/B testing tool waiting for a segment assignment that never arrives. Your page looks ready, but one request is blocking idle detection.
The fix is to intercept and abort requests you don't need:
await page.setRequestInterception(true);
page.on('request', (req) => {
const blocked = ['analytics', 'doubleclick', 'hotjar', 'segment.io'];
if (blocked.some(host => req.url().includes(host))) {
req.abort();
} else {
req.continue();
}
});
Block the noise, screenshot the signal.
The practical takeaway
For internal tools and pages you control: use waitForSelector. It's precise and fast.
For external pages: networkidle2 + a small fixed delay is the most reliable general-purpose approach. We use this as the default in ScreenshotRun when callers don't specify a custom wait strategy — it handles the majority of real-world pages without false triggers.
The goal is reproducible screenshots, not clever code. Whatever wait strategy you use, make sure it gives you the same result on the same page twice in a row. If it doesn't, you don't have a screenshot pipeline — you have a lottery.