· Updated · Research · By Simon Chiu

How Long Does a Screenshot Take? Latency, Phase by Phase

Where the seconds go in a web page screenshot: launch, DNS, TLS, load, wait, capture, encode and upload. A script that times each phase and a method to measure.

Line illustration of the RenderScreenshot mascot timing the steps of a screenshot with a stopwatch

A web page screenshot takes anywhere from under a second to the full timeout, and the total is the sum of eight phases. Most of the variation comes from two of them: how long the page takes to load and how long you wait after it loads. This post breaks a screenshot into those phases, shows which ones you control, gives you a script that times each phase separately, and explains the extra phases a screenshot API adds on top. It is part of our engineering notes.

Method and script as of October 15, 2026. This page publishes no latency figures for our API or any other: the section "Why no numbers yet" explains why, and what the published run will contain.

The eight phases

 ┌──────────┬──────────────┬──────────────┬─────────────┬──────────┬─────────┬────────┬─────────┐
 │ 1 launch │ 2 DNS + TCP  │ 3 TTFB +     │ 4 sub-      │ 5 wait   │ 6 fonts │ 7 cap- │ 8 store │
 │ browser  │   + TLS      │   HTML       │   resources │ strategy │ + paint │ ture + │ + send  │
 │          │              │              │   → load    │          │         │ encode │         │
 └──────────┴──────────────┴──────────────┴─────────────┴──────────┴─────────┴────────┴─────────┘
   you         the target's network          the target page          you         you      you
# Phase What happens Who controls it
1 Browser launch Start Chrome (or connect to a running one) and open a page You: reuse versus fresh launch
2 DNS, TCP, TLS Resolve the target's hostname, connect, negotiate TLS The target and the network path
3 TTFB and HTML The target's server generates the page and starts sending it The target
4 Sub-resources CSS, scripts, images, fonts; scripts run; the load event fires The target, plus your blocking rules
5 Wait strategy Anything after load: network idle, a selector, a fixed delay You
6 Fonts and paint Web fonts finish loading, layout settles Mostly the target
7 Capture and encode The compositor produces pixels; PNG, JPEG or WebP encoding You: viewport, scale factor, full page, format
8 Store and send Upload to storage, return the image or a link You

Phases 2 to 4 are the target's. You cannot make a slow site fast, but you can stop waiting for parts of it you do not need (phase 4, via blocking) and stop waiting longer than necessary (phase 5).

Phase 5 has a floor

The wait strategy is the phase people underestimate, because it has a built-in minimum. "Network idle" means "no requests for a quiet period": 500 ms in Puppeteer's networkidle0 and in our API's wait.until: "networkidle" (see the wait parameters). So a network-idle wait costs at least that quiet period after the last request, on every capture, even on a page that finished loading long ago. On pages with analytics beacons, long polling or a websocket, the quiet period never comes and the wait runs until the timeout.

Strategy Minimum added after load Risk
load 0 Single-page apps may not have rendered their data yet
domcontentloaded Negative: fires before load Images and fonts may be missing
networkidle The quiet period (500 ms) after the last request Never settles on chatty pages; hits the timeout
Wait for a selector Time until that element appears Fails if the selector is wrong or never appears
Fixed delay Exactly the delay Too short on slow pages, wasted time on fast ones

The fastest reliable choice is usually load plus a selector for the one element you need. Our wait strategies guide has the decision table for the API.

Phase 1: launch or reuse

A fresh browser per screenshot pays the launch cost every time; a reused browser pays it once. The isolation trade-off is real: a reused browser carries cookies, storage and cache between captures unless you open each page in a new browser context. Our renderer launches a fresh browser per capture for isolation; the architecture post explains why and what it costs.

Phase 7: pixels, not format, drive capture time

Capture time grows with the number of pixels: viewport width × height × device scale factor squared, and for full-page captures, the page height. A 1280×800 viewport at scale 2 is four times the pixels of scale 1. The encoded format changes the file size more than the capture time, and the file size drives phase 8. Time both on your own pages; the script below captures PNG and WebP of the same page for that reason.

Time every phase yourself

The script launches a fresh browser per URL (the naive self-hosted case), navigates to load, reads the page's own Navigation Timing entry for the network phases, then times the network-idle wait, font loading and two captures. One CSV row per URL per run.

// latency-breakdown.mjs: time every phase of one screenshot, per URL, per run.
// Node 22+, puppeteer@24.  Usage:
//   node latency-breakdown.mjs urls.txt 3 > phases.csv
import { readFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';

const [, , urlFile = 'urls.txt', runsArg = '3'] = process.argv;
const RUNS = Number(runsArg);

const urls = (await readFile(urlFile, 'utf8'))
  .split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));

const COLUMNS = [
  'url', 'run', 'launch_ms', 'dns_ms', 'connect_ms', 'tls_ms', 'ttfb_ms', 'download_ms',
  'dcl_ms', 'load_ms', 'idle_wait_ms', 'fonts_ms', 'png_ms', 'png_bytes', 'webp_ms', 'webp_bytes',
  'total_ms', 'error',
];
console.log(COLUMNS.join(','));

const since = (t) => Math.round(performance.now() - t);

for (let run = 1; run <= RUNS; run++) {
  for (const url of urls) {
    const row = { url, run };
    const t0 = performance.now();
    let browser;
    try {
      // Phase 1: a cold browser for every shot, as a naive self-hosted setup does.
      let t = performance.now();
      // chrome-headless-shell: the lean binary most screenshot servers run.
      browser = await puppeteer.launch({ headless: 'shell' });
      const page = await browser.newPage();
      await page.setViewport({ width: 1280, height: 800 });
      row.launch_ms = since(t);

      // Phase 2: navigation until the load event.
      await page.goto(url, { waitUntil: 'load', timeout: 45_000 });

      // Network phases come from the page's own Navigation Timing entry.
      const nav = await page.evaluate(() => performance.getEntriesByType('navigation')[0].toJSON());
      row.dns_ms = Math.round(nav.domainLookupEnd - nav.domainLookupStart);
      row.connect_ms = Math.round(nav.connectEnd - nav.connectStart);
      row.tls_ms = nav.secureConnectionStart > 0 ? Math.round(nav.connectEnd - nav.secureConnectionStart) : 0;
      row.ttfb_ms = Math.round(nav.responseStart - nav.requestStart);
      row.download_ms = Math.round(nav.responseEnd - nav.responseStart);
      row.dcl_ms = Math.round(nav.domContentLoadedEventEnd);
      row.load_ms = Math.round(nav.loadEventEnd);

      // Phase 3: the "wait" a screenshot API adds after load.
      t = performance.now();
      await page.waitForNetworkIdle({ idleTime: 500, timeout: 15_000 }).catch(() => {});
      row.idle_wait_ms = since(t);
      t = performance.now();
      await page.evaluate(() => document.fonts.ready);
      row.fonts_ms = since(t);

      // Phase 4: capture + encode, once per format.
      t = performance.now();
      const png = await page.screenshot({ type: 'png' });
      row.png_ms = since(t);
      row.png_bytes = png.length;
      t = performance.now();
      const webp = await page.screenshot({ type: 'webp', quality: 80 });
      row.webp_ms = since(t);
      row.webp_bytes = webp.length;
    } catch (err) {
      row.error = err.message.split('\n')[0].replaceAll(',', ';').slice(0, 120);
      process.exitCode = 1;
    } finally {
      await browser?.close();
      row.total_ms = since(t0);
      console.log(COLUMNS.map((c) => row[c] ?? '').join(','));
    }
  }
}

Notes on what each column means:

  • dcl_ms and load_ms are measured from the start of navigation, so they include DNS, connect, TLS and TTFB. The difference load_ms − ttfb_ms is roughly phase 4.
  • connect_ms includes tls_ms; TLS is the tail end of the connection.
  • idle_wait_ms can never be below 500 on a page that finished loading, because of the quiet period. If it is near 15,000, the page never went idle.
  • The second capture reuses the painted page, so webp_ms is capture plus WebP encoding without a second layout.
  • A network-idle timeout is caught and recorded as a long wait, not as an error. Navigation and capture errors set a non-zero exit code.

Tested on October 15, 2026 with Node.js 22.18.0 and Puppeteer 24.43.1 (chrome-headless-shell 148) on macOS. A sample row, to show the format, for https://example.com/:

url,run,launch_ms,dns_ms,connect_ms,tls_ms,ttfb_ms,download_ms,dcl_ms,load_ms,idle_wait_ms,fonts_ms,png_ms,png_bytes,webp_ms,webp_bytes,total_ms,error
https://example.com/,1,121,2,165,44,27,1,232,232,504,5,48,79353,64,34920,995,

One row from one machine is an illustration, not a benchmark. Two things in it are structural and will show up on any machine: the network-idle wait (504 ms) is the 500 ms quiet period plus a few milliseconds, and it is the largest single phase on a page this small; and the WebP file is less than half the size of the PNG of the same pixels.

Run it properly

  1. Use a URL list that looks like your traffic, 10 to 30 URLs: static pages, a single-page app, a long article with images, a page with a cookie banner.
  2. Run at least 3 rounds, interleaved (all URLs, then all URLs again), so a temporary slowdown of one site does not land on all its runs.
  3. Use an idle machine close to where your renderer will run. Network phases depend on the path to the target; capture phases depend on the CPU. A laptop on Wi-Fi measures a laptop on Wi-Fi.
  4. Report the median and p95 per phase, not the mean. One page that times out will drag a mean anywhere.
  5. Keep cold and warm apart. This script is all cold (new browser, empty cache). For warm numbers, reuse the browser and load each URL twice.

The phases a screenshot API adds

A hosted API runs the same eight phases somewhere else, and adds its own around them. For RenderScreenshot, from our code:

Added phase What it is Notes
Client to API DNS, TLS and request to api.renderscreenshot.com Measure with curl -w (below)
Auth and validation Key or signature check, parameter validation, preset merge Database work in Rails, before any browser starts
Cache lookup Find an identical recent render for your account A hit skips everything after this row, when you send cache.available
API to renderer HTTPS request from Rails to our Cloudflare Worker Held open for the whole render
URL safety checks DNS-over-HTTPS lookups of the target (A and AAAA) to reject private addresses Runs before navigation; adds a small fixed cost
Cookie banner handler When block.cookie_banners is on: a click and a 300 ms settle, or a CSS hide Only when requested
Upload The Worker writes the image to R2 Size-dependent
Response Rails returns the image (fetched back from storage) or a JSON body with the CDN link JSON mode avoids sending the bytes through the API

To see the client-side part of that from your own machine, curl reports its own phases:

curl -s -o shot.png -D headers.txt \
  -H "Authorization: Bearer $RS_API_KEY" \
  -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} bytes=%{size_download}\n' \
  "https://api.renderscreenshot.com/v1/screenshot?url=https%3A%2F%2Fexample.com&width=1280&height=800"
grep -i '^x-cache-hit' headers.txt

ttfb minus tls is roughly everything server-side: validation, the render and storage. X-Cache-Hit: true tells you the render phases did not run at all. The same -w format works against any HTTP screenshot API, which makes it a fair first comparison.

How to make screenshots faster

In order of effect, for most workloads:

  1. Do not render twice. Cache renders and serve the stored image. With our API, send cache.available: true for repeat requests, or embed the X-Cache-URL link instead of the API URL.
  2. Choose the cheapest wait that is correct. load plus a selector beats networkidle on chatty pages by the quiet period at least, and by the whole timeout at worst.
  3. Block what you do not need in the image: ads, trackers, chat widgets and video. Fewer requests mean an earlier load and an earlier idle.
  4. Capture fewer pixels. Use scale 1 unless you need retina output; avoid full-page captures when the top of the page is enough.
  5. Reuse browsers safely in self-hosted setups: one browser, a new context per job.
  6. Prefer WebP or JPEG for photos to cut upload and download time.

Our rendering performance guide maps each of these to API parameters.

Why no numbers yet

We have not published a phase breakdown of our production renders. Doing it properly needs timing from inside the Worker (launch, navigation, wait, capture and upload logged per request), a fixed corpus of test pages, several regions and a few days of runs, plus a method box like the one on our headless detection study. When it exists, it will appear on this page and in the screenshot API benchmark, with the raw CSV.

Until then, be suspicious of any single "average screenshot time" figure, including ours when it comes. Ask which wait strategy, which pages, which region, cold or warm, and which percentile. For capacity under load rather than single-request latency, see load testing a screenshot API.

Try the curl timing on our API

The curl -w command above works with a free account. Run it twice with cache_available=true added to the query to see the difference between a render and a cache hit.

Related in this topic