Every screenshot tool has to answer one question: when is the page done?
Puppeteer offers waitUntil options, and none of them answers it. load waits
for a thing 16 of the 20 SaaS pages in my benchmark never fire. networkidle2
waits for quiet that ad refresh and analytics beacons never deliver, which
produced 18 hard timeouts across 202 sites. domcontentloaded returns while
the page is still a skeleton.
So Screenshotline navigates to
domcontentloaded and then spends one bounded budget on the things that
actually change the picture. This post is that budget, and the page that forced
each part of it.
The budget has to fit inside the render
Before anything else, the deadline:
const growthDeadline = Math.min(
Date.now() + config.maxSettleMs,
startedAt + options.timeout - 6000,
);
The second line is the one that matters. Without it, a page that took 24 seconds to navigate still settled for the full budget and sailed past a 30-second ceiling. Every wait below is spent from this one deadline, in priority order, and anything that runs out of time is skipped rather than extended.
Network quiet, but only as a hint
Quiet is useful evidence and a terrible stopping rule on its own. It is checked, bounded, and then treated as one input to a decision rather than the decision.
Finite animations, not all animations
A page can be fully loaded and still visually mid-flight: a nav fading in, a heading un-blurring, a hero sliding up. Capture then and you get a blurred half-drawn frame that no amount of waiting for the network would have prevented, because nothing is loading.
deccan.ai forced this. It goes quiet at 5 seconds, then runs four entrance animations at about 8. The default settle exited in the gap, so the capture varied run to run and often had a blurred nav and no hero.
The fix waits for running animations to finish, with one exclusion that makes it possible at all:
const pending = document.getAnimations().filter((a) => {
if (a.playState !== 'running') return false;
return a.effect?.getComputedTiming?.().iterations !== Infinity;
});
That same page rotates a word in its headline forever. Waiting for every animation to end would never return. Only the ones that have an end count.
Big media, briefly
A hero video still buffering is worth a short wait. One that will never buffer is not, and news sites are full of those: large autoplay players headless Chrome never starts. Letting them block the whole settle took news p50 from 6.9s to 10.9s, so media gets its own shorter deadline inside the budget.
Only big media counts, 200 by 200 pixels and in view. A 16px tracking pixel or a lazy thumbnail that never loads is invisible in the result, and if it counted it would hold every capture to the full budget. Images are checked once rather than every round, because re-testing them each round took news p50 from 6.9s to 12.3s for the same reason.
Video is re-checked every round, though, because of a window that opened on a
slow machine. deccan.ai builds its hero <video> from script, so the one-shot
check ran before the element existed. querySelectorAll returned nothing,
.every() on an empty list is true, and we captured a black rectangle.
The adaptive tail
Then the part that rescues the pages a fixed wait ruins.
timesofindia.indiatimes.com returns 446 characters of text at
settle=5000 and 48,755 at 15000, and the short version comes back as a
clean 200. Raising the default for everyone would fix that page and ruin p50
for the rest, so instead the renderer watches whether content is still arriving
and extends only for pages that are still working.
Each round reads the body's text length and whether big media is ready, with a ceiling on the read itself. Under 5% growth counts as settled, and two consecutive quiet rounds wins.
Stability alone is not enough to stop:
const trustworthy = mediaReady && (networkQuiet || lastLength >= 1000);
if (trustworthy && len > 0 && len <= lastLength * 1.05) {
stableRounds += 1;
if (stableRounds >= 2) break;
}
TOI sits flat at 446 characters for seconds before the real content lands. Flat is not the same as finished, so an early exit needs a second opinion: either the network actually went quiet, or there is already a plausible amount of text.
There is also an exit at the other end. Once a page has plainly substantial content, 5,000 characters and growth under 5%, the tail stops watching. The tail exists to rescue under-rendered pages, not to add seconds to pages that are already complete. Without that cutoff it cost 5 extra timeouts and 2 seconds of p50 across the 202-page sweep on its first outing.
Every read is bounded, including this one
Reading innerText forces a layout, so it runs on the page's main thread. If
that thread is wedged, the evaluate never returns and never rejects.
vercel.com does exactly this: readyState complete at 3.6 seconds, then four
chunks in flight forever and no answers to anything.
This was the last unbounded await in the settle path, and it hung the whole render until the wall-clock ceiling killed the browser: a 504 on a page curl fetches in 240ms. Now a null result means the page has stopped talking, so the loop stops asking and captures what is on screen.
What to copy
If you are waiting on pages you do not control:
- Spend one budget, not several waits. Every check subtracts from the same deadline, and the deadline leaves room for the work that follows it.
- Make quiet a hint, not a verdict. Plenty of real pages are never quiet, and plenty of empty ones are.
- Wait for finite animations only. Infinite ones are common and will hold you forever.
- Watch the content, not the clock. Growth in extracted text is the only signal that tracks what the reader will actually see.
- Bound the measurement itself. The page you are measuring can stop answering, and a measurement without a ceiling becomes the outage.
The whole settle is in
src/render.js,
with the page names in the comments. If you would rather not own it, the hosted
API has 500 free renders a month, and the same renderer
self-hosts with Docker.