I wanted to know what waitUntil: 'load', 'networkidle' and document.fonts.ready each guarantee before a screenshot, so I built a local page that is slow in four different ways and checked what was true when each wait returned. Two common habits turned out not to do what they seem to.
Everything below ran on 2026-11-02: Playwright 1.63.0, Chromium 153.0.8010.12, Node v24.22.2, Linux. The server is local, so nothing here touches anyone else's site. The delays are numbers I picked, so read the results as "this can happen", not as measurements of the web.
The fixture
One page, served by a small Node server that delays each resource on purpose:
- a web font (600 ms) used by the page text
- a hero
<img>(400 ms) - a price that appears 700 ms after the
loadevent, via a timer and then afetch(300 ms) - a second web font, used only by the price once it appears
- a
loading="lazy"image far below the fold - an image that an
IntersectionObserverloads when it scrolls into view
Run npm i playwright@1.63.0 and save this as fixture.js. The font path is a Debian/Ubuntu one; point it at any TTF.
// fixture.js
const http = require('http');
const fs = require('fs');
const FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf';
const delays = { '/font.ttf': 600, '/font2.ttf': 600, '/hero.svg': 400, '/lazy.svg': 300, '/io.svg': 300, '/api/price': 300 };
const page = `<!doctype html><meta charset=utf-8>
<style>
@font-face { font-family: Probe; src: url(/font.ttf); }
@font-face { font-family: Probe2; src: url(/font2.ttf); }
.late { font-family: Probe2, monospace; }
body { margin:0; font: 40px Probe, monospace; }
h1 { margin: 20px; }
img { display:block; width: 600px; height: 200px; margin: 20px; }
#spacer { height: 6000px; }
#price { margin: 20px; min-height: 50px; }
</style>
<h1>Quarterly report</h1>
<img id=hero src=/hero.svg>
<div id=price>price: (loading)</div>
<div id=spacer></div>
<img id=lazy loading=lazy src=/lazy.svg>
<img id=io data-src=/io.svg>
<script>
// content that arrives after load: a timer, then a request
addEventListener('load', () => setTimeout(async () => {
const r = await fetch('/api/price'); const j = await r.json();
const el = document.getElementById('price'); el.className = 'late'; el.textContent = 'price: ' + j.price;
}, 700));
new IntersectionObserver((entries, obs) => {
for (const e of entries) if (e.isIntersecting) { e.target.src = e.target.dataset.src; obs.unobserve(e.target); }
}).observe(document.getElementById('io'));
</script>`;
const svg = (c, t) => `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="200"><rect width="600" height="200" fill="${c}"/><text x="20" y="110" font-size="48" fill="white">${t}</text></svg>`;
module.exports = function start(port = 0) {
const server = http.createServer((req, res) => {
const send = () => {
if (req.url === '/') { res.setHeader('content-type', 'text/html'); return res.end(page); }
if (req.url === '/font.ttf' || req.url === '/font2.ttf') { res.setHeader('content-type', 'font/ttf'); return res.end(fs.readFileSync(FONT)); }
if (req.url === '/hero.svg') { res.setHeader('content-type', 'image/svg+xml'); return res.end(svg('#c0392b', 'hero')); }
if (req.url === '/lazy.svg') { res.setHeader('content-type', 'image/svg+xml'); return res.end(svg('#2471a3', 'lazy')); }
if (req.url === '/io.svg') { res.setHeader('content-type', 'image/svg+xml'); return res.end(svg('#1e8449', 'io')); }
if (req.url === '/api/price') { res.setHeader('content-type', 'application/json'); return res.end('{"price":"42.00"}'); }
res.statusCode = 404; res.end();
};
setTimeout(send, delays[req.url] || 0);
});
return new Promise(r => server.listen(port, '127.0.0.1', () => r(server)));
};
What is true when each wait returns
probe.js runs six waits and prints what is true of the page the moment each one returns.
// probe.js
const { chromium } = require('playwright');
const start = require('./fixture');
// What is true of the page at the moment each wait returns?
const state = page => page.evaluate(() => {
const loaded = family => [...document.fonts].some(f => f.family === family && f.status === 'loaded');
return {
font: loaded('Probe'),
lateFont: loaded('Probe2'),
hero: document.getElementById('hero').naturalWidth > 0,
price: document.getElementById('price').textContent.includes('42.00'),
};
});
const priceIn = () => document.getElementById('price').textContent.includes('42.00');
const waits = {
'domcontentloaded': (p, u) => p.goto(u, { waitUntil: 'domcontentloaded' }),
'load': (p, u) => p.goto(u),
'networkidle': (p, u) => p.goto(u, { waitUntil: 'networkidle' }),
'load + fonts.ready': async (p, u) => { await p.goto(u); await p.evaluate(() => document.fonts.ready); },
'fonts, then selector': async (p, u) => {
await p.goto(u);
await p.evaluate(() => document.fonts.ready);
await p.waitForFunction(priceIn);
},
'selector, then fonts': async (p, u) => {
await p.goto(u);
await p.waitForFunction(priceIn);
await p.evaluate(() => document.fonts.ready);
},
};
(async () => {
const server = await start();
const url = `http://127.0.0.1:${server.address().port}/`;
const browser = await chromium.launch();
console.log('playwright', require('playwright/package.json').version, 'chromium', browser.version());
for (const [name, wait] of Object.entries(waits)) {
const page = await browser.newPage();
await wait(page, url);
console.log(name.padEnd(22), JSON.stringify(await state(page)));
await page.close();
}
await browser.close(); server.close();
})();
Output:
playwright 1.63.0 chromium 153.0.8010.12
domcontentloaded {"font":false,"lateFont":false,"hero":false,"price":false}
load {"font":true,"lateFont":false,"hero":true,"price":false}
networkidle {"font":true,"lateFont":false,"hero":true,"price":false}
load + fonts.ready {"font":true,"lateFont":false,"hero":true,"price":false}
fonts, then selector {"font":true,"lateFont":false,"hero":true,"price":true}
selector, then fonts {"font":true,"lateFont":true,"hero":true,"price":true}
I ran it four times and the booleans were identical every time. Three things stand out.
load already covered the font and image. The font used by text in the initial HTML was loaded when load fired. So document.fonts.ready right after load, a common pairing, added nothing for this font.
networkidle missed the price. It waits for 500 ms with no network connections. My timer fires 700 ms after load, so the page looked idle first. Playwright's own docs mark it as discouraged: "Don't use this method for testing, rely on web assertions to assess readiness instead" (Playwright docs). A page that fetches data on a slower timer than your idle window will screenshot half-empty, and it will do so only sometimes.
Order matters for document.fonts.ready. In fonts, then selector, the second font (lateFont) was still not loaded when the function returned. The promise resolved before the price existed, so there was nothing to wait for yet. In selector, then fonts, the font load had started by the time I asked, and fonts.ready waited for it. MDN says the promise resolves "once the document has completed loading fonts, layout operations are completed, and no further font loads are needed" (MDN). That is a statement about the moment you call it. It cannot know about text that has not been inserted.
So: wait for your own content first, then fonts.
Lazy images in a full-page screenshot
fullPage: true captures the whole document, but nothing scrolls the page, so images that only load near the viewport stay blank. I checked the colour of one pixel inside each lazy image in a 700x6758 capture. Blue means the image was drawn, white means blank.
Native loading="lazy" can be overridden by switching it to eager. That does nothing for images loaded by JavaScript when they intersect, so for those you have to scroll. This helper does the scrolling, with the content-then-fonts order from above:
// settle.js
// Wait until a page is worth screenshotting. `ready` is a function that returns
// true once the page's own content has arrived (the part only you can define).
module.exports = async function settle(page, ready) {
await page.waitForFunction(ready); // 1. content first
await page.evaluate(() => document.fonts.ready); // 2. then fonts for that content
const step = await page.evaluate(() => innerHeight); // 3. wake lazy loaders
const height = await page.evaluate(() => document.documentElement.scrollHeight);
for (let y = 0; y < height; y += step) {
await page.evaluate(y => scrollTo(0, y), y);
await page.waitForTimeout(50);
}
await page.evaluate(() => scrollTo(0, 0));
await page.evaluate(() => document.fonts.ready); // fonts that lazy content pulled in
await page.waitForFunction(() => [...document.images].every(i => i.complete));
};
lazy.js takes three full-page screenshots and prints the pixel colour inside each lazy image:
// lazy.js
const { chromium } = require('playwright');
const start = require('./fixture');
const settle = require('./settle');
// colour of one pixel of a PNG, decoded in a scratch page
async function pixel(scratch, png, x, y) {
return scratch.evaluate(async ([b64, x, y]) => {
const bmp = await createImageBitmap(await (await fetch('data:image/png;base64,' + b64)).blob());
const c = new OffscreenCanvas(bmp.width, bmp.height);
const ctx = c.getContext('2d'); ctx.drawImage(bmp, 0, 0);
const d = ctx.getImageData(x, y, 1, 1).data;
return `rgb(${d[0]},${d[1]},${d[2]})`;
}, [png.toString('base64'), x, y]);
}
const priceReady = () => document.getElementById('price').textContent.includes('42.00');
const variants = {
'nothing extra': async () => {},
'loading=eager': async page => {
await page.evaluate(() => document.querySelectorAll('img[loading=lazy]').forEach(i => { i.loading = 'eager'; }));
await page.waitForFunction(() => [...document.images].every(i => i.complete));
},
'settle() with scrolling': page => settle(page, priceReady),
};
(async () => {
const server = await start();
const url = `http://127.0.0.1:${server.address().port}/`;
const browser = await chromium.launch();
const scratch = await browser.newPage();
for (const [name, prepare] of Object.entries(variants)) {
const page = await browser.newPage({ viewport: { width: 700, height: 600 } });
await page.goto(url);
await page.waitForFunction(priceReady);
await prepare(page);
const top = id => page.evaluate(id => Math.round(document.getElementById(id).getBoundingClientRect().top + scrollY) + 10, id);
const png = await page.screenshot({ fullPage: true });
console.log(name.padEnd(26), 'native lazy:', await pixel(scratch, png, 30, await top('lazy')),
' observer lazy:', await pixel(scratch, png, 30, await top('io')));
await page.close();
}
await browser.close(); server.close();
})();
Output:
nothing extra native lazy: rgb(255,255,255) observer lazy: rgb(255,255,255)
loading=eager native lazy: rgb(36,113,163) observer lazy: rgb(255,255,255)
settle() with scrolling native lazy: rgb(36,113,163) observer lazy: rgb(30,132,73)
With no extra step, both lazy images came out as white rectangles. Flipping loading to eager fixed only the native one. Scrolling fixed both.
What this does not cover
-
settle()needs areadycondition that you write per page. There is no generic one. I used a text match; for a real page it might be a selector, or a response you waited for. -
[...document.images].every(i => i.complete)is also true for an image that failed to load. A broken image gives you a broken-image icon in the capture and no error. - CSS
background-image,<video>posters, canvas and WebGL are not covered by any of this. - Scrolling can trigger things you do not want: infinite scroll that never ends, analytics events, sticky headers that change on scroll. On an infinite feed, cap the loop.
- Animations. If something fades in over a second, none of these waits notice. Playwright's
screenshothas ananimations: 'disabled'option that fast-forwards finite CSS animations (docs); I did not test it here. - My fixture is small and local. On a real page, the same four problems will arrive mixed together, and the timing will not repeat as neatly as it did here.
What I would use
For a screenshot job on pages you control, wait on something the page itself marks as done, such as a data-ready attribute, and skip guessing with network heuristics. For pages you do not control, use load, then the content condition, then fonts, then the scroll-and-images step. Treat networkidle as a last resort, and cap the whole thing with a timeout so a hung page costs you seconds, not a worker.
Written with AI assistance. Every code sample was run on 2026-11-02 with Playwright 1.63.0 on Chromium 153.0.8010.12 and Node v24.22.2; the output above is from that run.
Top comments (0)