# How to screenshot a list of URLs in bulk

> Screenshot hundreds or thousands of URLs: a queue simulator that says how long it takes, a short Node.js script, and what fails at scale.

Playbook by Serghei Pogor, founder of CurlShot (https://curlshot.com/about/serghei-pogor). Published 28 October 2026. Page for people: https://curlshot.com/blog/bulk-screenshots-list-of-urls

To screenshot a list of URLs in bulk, put them in a queue, render a fixed number at once, stay under the service's requests per minute, retry 429 and 5xx answers with a growing pause, and write one manifest row per URL. The job takes the larger of two numbers: URLs x seconds per render / renders at once, and URLs / requests per minute x 60. For example, 1,000 URLs at 4 seconds each, 5 at once and 60 a minute take about 16 min 40 s, before retries, and the rate limit sets that time. With CurlShot, one POST to the bulk endpoint starts up to 100 screenshots and a webhook or one status call collects them.

## Key facts

- Time for a bulk screenshot job = the larger of (URLs x seconds per render / renders at once) and (URLs / requests per minute x 60).
- Only one limit is the bottleneck at a time. In the example of 1,000 URLs, 4 s a render, 5 at once and 60 a minute, concurrency alone would take 13 min 20 s and the rate limit 16 min 40 s, so raising concurrency changes nothing.
- A sitemap file holds at most 50,000 URLs and 50 MB uncompressed; larger sites split the list across a sitemap index of up to 50,000 sitemaps.
- Retry-After is either a whole number of seconds or an HTTP date. Retry 429 and 5xx answers after that wait; never retry other 4xx answers.
- Write a manifest with url, status, file and error for every URL. It is what lets you rerun only the failures.
- The CurlShot bulk endpoint takes up to 100 requests per call, counts one rate-limit request per item, and counts only successful renders against the quota: failed jobs and cache hits are free.

## How long does a bulk screenshot job take?

A bulk screenshot job takes the larger of two times: the time its concurrency allows and the time its rate limit allows. This is a model, not a benchmark. Every input is an assumption to replace with your own number.

```text
requests = URLs x (1 + f + f x f)
time by concurrency = requests x seconds per render / renders at once
time by rate limit = requests / requests per minute x 60
time = the larger of the two
still failing after 3 tries = URLs x f x f x f
```

`f` is the share of attempts that fail (3% is 0.03). The model tries each URL up to 3 times.

Worked example, with example inputs only (1,000 URLs, 4 s per render, 5 at once, 60 requests a minute, 3% of attempts failing):

```text
requests = 1,000 x (1 + 0.03 + 0.03 x 0.03) = 1,031
time by concurrency = 1,031 x 4 / 5 = 825 s = 13 min 45 s
time by rate limit = 1,031 / 60 x 60 = 1,031 s = 17 min 11 s
time = the larger = 17 min 11 s
still failing after 3 tries = 1,000 x 0.03 x 0.03 x 0.03 = 0.027, so fewer than 1
```

Your rate limit, not your concurrency, is what is slowing this job. At 60 requests a minute and 4 s a render, only 4 render slots are busy on average. More than 4 at once changes nothing; a higher rate limit does.

The same formula with no failures, for other sizes (examples of the arithmetic, not measurements):

| URLs | Per render | At once | Per minute | Time by concurrency | Time by rate limit | Job takes | Bottleneck |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 100 | 4 s | 1 | 10 | 6 min 40 s | 10 min | 10 min | Rate limit |
| 1,000 | 4 s | 2 | 60 | 33 min 20 s | 16 min 40 s | 33 min 20 s | Concurrency |
| 1,000 | 4 s | 5 | 60 | 13 min 20 s | 16 min 40 s | 16 min 40 s | Rate limit |
| 10,000 | 4 s | 10 | 120 | 1 h 7 min | 1 h 23 min | 1 h 23 min | Rate limit |
| 10,000 | 8 s | 10 | 120 | 2 h 13 min | 1 h 23 min | 2 h 13 min | Concurrency |
| 50,000 | 4 s | 20 | 600 | 2 h 47 min | 1 h 23 min | 2 h 47 min | Concurrency |

To find your own seconds per render, time 20 URLs from your list before you start the whole job.

## Where does the list of URLs come from?

### A spreadsheet or CSV column

You already have the addresses: a client list, a directory, an export from analytics or a CMS.

- Export the sheet as CSV, keep the address column, and write one URL per line to `urls.txt`.
- The one-liner drops the header row, quotes and Windows line endings, and removes exact duplicates. It assumes no comma inside the address; if yours have one, export that single column on its own.

```bash
# pages.csv has a header row and the address in column 1
tail -n +2 pages.csv | cut -d, -f1 | tr -d '"\r' | sort -u > urls.txt
```

### A sitemap.xml

You want every page a site lists for search engines, which is usually every page that matters.

- A sitemap is an XML file with a `<urlset>` root, one `<url>` per page and the address in `<loc>`. One file holds at most 50,000 URLs and 50 MB uncompressed, and may be gzipped.
- Larger sites publish a sitemap index: a `<sitemapindex>` root whose `<sitemap>` entries each point at another sitemap in `<loc>`, up to 50,000 of them.
- The address of the sitemap is often in `robots.txt`, on a line that starts with `Sitemap:`. Values in the file are entity-escaped, so `&amp;` has to become `&` again.

```js
// node sitemap.mjs https://example.com/sitemap.xml > urls.txt
import { gunzipSync } from 'node:zlib'

const decode = (text) =>
  text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&')

async function locs(sitemapUrl) {
  const response = await fetch(sitemapUrl)
  if (!response.ok) throw new Error(`${sitemapUrl}: HTTP ${response.status}`)
  let bytes = Buffer.from(await response.arrayBuffer())
  if (bytes[0] === 0x1f && bytes[1] === 0x8b) bytes = gunzipSync(bytes) // a .xml.gz file
  const xml = bytes.toString('utf8')
  const found = [...xml.matchAll(/<loc>\s*(.*?)\s*<\/loc>/gs)].map((match) => decode(match[1]))
  if (!/<sitemapindex[\s>]/.test(xml)) return found
  const all = [] // a sitemap index lists other sitemaps: read each one in turn
  for (const child of found) all.push(...(await locs(child)))
  return all
}

console.log([...new Set(await locs(process.argv[2]))].join('\n'))
```

### A crawl of your own site

There is no sitemap, or you want the pages people can actually reach by clicking.

- A crawl starts at one page, collects the links that stay on the same origin, and repeats for each new page until it has seen them all or hits a cap.
- Read `/robots.txt` first and leave out what it disallows. This sketch does not parse it, which is acceptable only on a site you run. It pauses a second between requests and stops at 500 pages.
- A crawl finds only pages that are linked. A sitemap finds only pages that are listed. For a full inventory, merge the two lists and deduplicate.

```js
// node crawl.mjs https://example.com > urls.txt   (a site you run)
const start = new URL(process.argv[2])
const MAX_PAGES = 500
const PAUSE_MS = 1000 // one request a second to the one host

const seen = new Set([start.href])
const queue = [start.href]
const pages = []
for (let i = 0; i < queue.length && pages.length < MAX_PAGES; i++) {
  const response = await fetch(queue[i], { headers: { 'User-Agent': 'site-inventory (you@example.com)' } })
  if (response.ok && (response.headers.get('content-type') || '').includes('text/html')) {
    pages.push(queue[i])
    for (const [, href] of (await response.text()).matchAll(/<a\s[^>]*?href=["']([^"'#]+)/gi)) {
      let link
      try { link = new URL(href, response.url) } catch { continue }
      if (link.origin === start.origin && !seen.has(link.href)) { seen.add(link.href); queue.push(link.href) }
    }
  }
  await new Promise((resolve) => setTimeout(resolve, PAUSE_MS))
}
console.log(pages.join('\n'))
```

## The bulk screenshot job as a pipeline

1. **List.** One URL per line, from a CSV, a sitemap or a crawl.
2. **Normalise.** Drop fragments and tracking parameters, then remove duplicates.
3. **Queue.** Every URL waits its turn. Nothing starts all at once.
4. **Render.** A fixed number run at the same moment, spaced to fit the rate limit.
5. **Retry.** A 429 or 5xx waits, then tries again. The wait doubles each time.
6. **Store.** Each file gets a name you can compute again from its URL.
7. **Manifest.** One row per URL: url, status, file, error.

Normalising and deduplicating before the queue is the cheapest step of the job: every duplicate removed is a render you do not pay for.

```js
function normalise(raw) {
  const url = new URL(raw.trim()) // throws on anything that is not a full address
  url.hash = '' // #section never reaches the server
  for (const key of [...url.searchParams.keys()]) {
    if (/^(utm_|fbclid$|gclid$)/.test(key)) url.searchParams.delete(key)
  }
  url.searchParams.sort()
  return url.href // the host comes back in lower case
}

const urls = [...new Set(lines.filter(Boolean).map(normalise))]
```

## A bulk screenshot script in Node.js, with no packages

The script reads `urls.txt`, runs a fixed number of requests at once, stays under a per-minute limit, retries `429` and `5xx` answers, saves each file under a safe name and writes `results.csv`. It needs Node.js 18 or later, where `fetch` is built in. Set `SCREENSHOT_ACCESS_KEY` in the environment first.

```js
// node bulk.mjs   (Node.js 18 or later, no packages)
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'

const API = 'https://curlshot.com/api/v1/screenshot'
const KEY = process.env.SCREENSHOT_ACCESS_KEY
const CONCURRENCY = 4 // renders at once
const PER_MINUTE = 60 // requests per minute
const TRIES = 4

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const lines = (await readFile('urls.txt', 'utf8')).split(/\r?\n/).map((line) => line.trim())
const urls = [...new Set(lines.filter(Boolean))]

// Readable and unique: the host and path, plus 8 characters of a hash of the whole URL.
function fileName(url) {
  const { hostname, pathname } = new URL(url)
  const slug = (hostname + pathname).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
  return `${slug.slice(0, 80)}-${createHash('sha256').update(url).digest('hex').slice(0, 8)}.png`
}

// Rate limit: every request, retries included, takes the next free start time.
let nextStart = 0
async function takeTurn() {
  const at = Math.max(Date.now(), nextStart)
  nextStart = at + 60_000 / PER_MINUTE
  await sleep(at - Date.now())
}

// Retry-After is a number of seconds or an HTTP date. Without it: 1 s, 2 s, 4 s.
function retryDelay(response, attempt) {
  const header = response?.headers.get('retry-after')
  const ms = header ? (/^\d+$/.test(header) ? header * 1000 : Date.parse(header) - Date.now()) : NaN
  return ms > 0 ? ms : 1000 * 2 ** (attempt - 1) + Math.random() * 250
}

async function capture(url) {
  for (let attempt = 1; ; attempt++) {
    await takeTurn()
    const query = new URLSearchParams({ url, format: 'png' })
    const response = await fetch(`${API}?${query}`, { headers: { 'X-Access-Key': KEY } }).catch(() => null)
    if (response?.ok) {
      const file = fileName(url)
      await writeFile(`shots/${file}`, Buffer.from(await response.arrayBuffer()))
      return { url, status: 'ok', file, error: '' }
    }
    const retryable = !response || response.status === 429 || response.status >= 500
    if (!retryable || attempt === TRIES) {
      const body = response ? await response.json().catch(() => ({})) : {}
      return { url, status: 'failed', file: '', error: body.error_code || (response ? `HTTP ${response.status}` : 'network error') }
    }
    await sleep(retryDelay(response, attempt))
  }
}

await mkdir('shots', { recursive: true })
const results = []
let next = 0
async function worker() {
  while (next < urls.length) results.push(await capture(urls[next++]))
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker))

const cell = (value) => `"${String(value).replace(/"/g, '""')}"`
const rows = results.map((r) => [r.url, r.status, r.file, r.error].map(cell).join(','))
await writeFile('results.csv', ['url,status,file,error', ...rows].join('\n') + '\n')
console.log(`${results.filter((r) => r.status === 'ok').length} of ${urls.length} saved. See results.csv.`)
```

| Part | What it does |
| --- | --- |
| The queue | `CONCURRENCY` workers share one counter, `next`. Each takes the next URL when it finishes the last, so exactly that many renders are open at once. |
| The rate limit | `takeTurn` hands out start times `60 / PER_MINUTE` seconds apart. Retries take a turn too, so the script never sends faster than the limit. |
| Retries | A network error, a `429` and any `5xx` are tried again, up to `TRIES` times. A `4xx` such as a bad address or an empty quota is recorded at once: asking again cannot fix it. |
| Retry-After | The header is either a whole number of seconds (`Retry-After: 120`) or an HTTP date (`Retry-After: Fri, 31 Dec 1999 23:59:59 GMT`). The script reads both, and falls back to 1, 2, 4 seconds plus a little randomness. |
| File names | The host and path become a slug of `a-z`, `0-9` and hyphens, cut at 80 characters, followed by 8 hex characters of the SHA-256 of the whole URL, so two addresses never share a file. |
| The manifest | `results.csv` has one row per URL with `url`, `status`, `file` and `error`. Every cell is quoted, so an address with a comma stays in one column. |

The script passes `node --check`. Its queue was also run with `fetch` replaced by a stub that answers `429` with both forms of `Retry-After`, `500`, `400` and `200`: it never had more than `CONCURRENCY` requests open, kept its starts `60 / PER_MINUTE` seconds apart, waited as long as `Retry-After` asked, and wrote one row per URL. It was not run against live pages for this article, so the timings in the model above are yours to measure.

## How do you take thousands of screenshots without getting blocked?

Capturing thousands of pages of someone else's site is load on their server. The practice is short:

- **Space the requests to one host.** A thousand URLs on a thousand hosts is one visit each. A thousand URLs on one host is a load test. Sort the list so hosts alternate, or cap one host at a request every second or two.
- **Prefer your own sites, or ask.** For someone else’s site, send its owner a short note that names the pages, the pace and the hour. An allowlist entry on their side is also what prevents the bot checks.
- **Read robots.txt where it applies.** The rules live at `/robots.txt` on each host and are written for automatic clients that follow links. RFC 9309 says of them: "These rules are not a form of access authorization." They are a request, and honouring it is what keeps crawlers welcome.
- **Treat a broken robots.txt as "stay out".** Under RFC 9309, a `robots.txt` that answers `4xx` means there are no rules, and one that answers `5xx` means a crawler "MUST assume complete disallow". Fetch it once per host and keep it for a day at most.
- **Slow down when told to.** A `429` or `503` from the site, or a run of timeouts, means stop and wait. Honour `Retry-After` when it is there. RFC 9309 defines no crawl delay field, so a site’s `Crawl-delay` line is a convention: follow it anyway.
- **Run big jobs off-peak.** Each screenshot loads the page with all its images, scripts and fonts, which is more work for the site than a plain fetch of the HTML. Night in the site’s own time zone costs it the least.

CurlShot renders the address you give it. It does not read `robots.txt` for you, so this part is the job of whoever builds the list. This is practice, not legal advice.

## How to name and store bulk screenshot files

| Scheme | Example | Good | Bad |
| --- | --- | --- | --- |
| Slug of the URL | `example-com-pricing.png` | Readable. You can find a page by eye. | `/a-b` and `/a/b` become the same name. Long addresses need cutting, and query strings make it worse. |
| Hash of the URL | `23a538fde85c5907.png` | Fixed length, safe on every system, unique per URL, and you can compute it again later. | Means nothing to a person. Useless without the manifest. |
| Slug plus short hash | `example-com-pricing-23a538fd.png` | Readable and unique. This is what the script above writes. | Slightly longer names. Still changes when the URL changes by one character. |
| Date folders | `2026-10-28/example-com-pricing-23a538fd.png` | Keeps each run apart, so you can compare a page across days and delete a whole run at once. | Storage grows with every run. Decide how many runs to keep before the first one. |

A manifest beats a clever file name. A name can hold one fact, the manifest holds all of them: the exact URL, whether it worked, where the file is and why it failed. With it you can rerun only the failed rows.

```csv
"url","status","file","error"
"https://example.com/","ok","example-com-0f115db0.png",""
"https://example.com/pricing","ok","example-com-pricing-23a538fd.png",""
"https://example.com/old-page","failed","","navigation_failed"
```

## What fails when you screenshot thousands of URLs

| Problem | How it shows up in the results | What to do |
| --- | --- | --- |
| Timeouts on slow pages | A failed row with `timeout` (status `504`). The same few URLs fail on every run. | Retry once or twice, then give those URLs a longer `timeout` (30 seconds by default, 90 at most) instead of more retries. |
| Bot checks on a fraction of hosts | Often a row that says `ok`: the screenshot is a challenge or "access denied" page. Look for files that are unusually small or all alike. | Slow down for that host and ask its owner to allow your job. Open a sample of files by eye before you trust the run. |
| Redirects to a login | A row that says `ok` and a screenshot of a sign-in form. Many pages produce the identical file. | Take those URLs out of the public list and capture them in a separate, signed-in job. |
| Duplicate URLs with tracking parameters | Two rows, two files, one page: `?utm_source=...` and the plain address. Quota spent twice. | Normalise before queuing: drop fragments and tracking parameters, sort the rest, then deduplicate. |
| Pages that never go idle | `timeout` on pages that look loaded: a chat widget, a video or analytics keeps the network busy, so `wait_until=networkidle` never arrives. | Use `wait_until=load` (the default) with a short `delay`, or `wait_for_selector` for the element that proves the page is ready. |
| Quota running out mid-job | Every row after a certain point fails with `quota_exceeded` (status `402`). | Do not retry: waiting seconds does not help. Check what is left before you start, and rerun only the failed rows from the manifest afterwards. |

The error codes in this table are the ones CurlShot returns. Other services name them differently.

## The CurlShot bulk endpoint, exactly

| Part | How it works |
| --- | --- |
| Endpoint | `POST /bulk`, with the access key in the `X-Access-Key` header. |
| Body | JSON: `requests`, a list of option sets, and an optional `webhook_url` for all of them. Each item needs exactly one of `url`, `html` or `markdown`, and takes every option of a single request. |
| Items per call | Up to 100. A plan can allow fewer, or no bulk calls at all (`feature_not_available`). Split a longer list into several calls. |
| Answer | Status `202` at once, with `batch_id`, `batch_url` and `jobs`: one `job_id`, `status`, `job_url` and `status_url` per request, in the order you sent them. |
| Validation | The whole list is checked before anything is queued. One wrong item answers `invalid_options` with its `index` and `field`, and no job starts. |
| Polling | `GET` the `batch_url`: `status` (`processing` or `done`), `total`, `counts` (`queued`, `processing`, `done`, `failed`) and every job. Status calls are free and have their own rate limit: 10 times the plan’s, never less than 300 a minute. |
| Webhook | One signed `POST` per job, not one per batch, with `event` `screenshot.completed` or `screenshot.failed` and the `batch_id`. A failed delivery is tried again, 6 tries in all. |
| A finished job | `screenshot.url`, `format`, `bytes`, `width`, `height`, `render_ms`, `cached` and `expires_at`. A failed one has `error_code` and `error_message` instead. Download the file before `expires_at`. |

```bash
curl -X POST "https://curlshot.com/api/v1/bulk" \
  -H "X-Access-Key: YOUR_ACCESS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://example.com/hooks/screenshot",
    "requests": [
      {
        "url": "https://example.com/",
        "format": "webp",
        "viewport_width": 1280,
        "viewport_height": 800,
        "block_cookie_banners": true,
        "cache": true
      },
      {
        "url": "https://example.com/pricing",
        "full_page": true,
        "format": "jpeg",
        "timeout": 60
      },
      {
        "url": "https://example.com/blog",
        "viewport_device": "iphone_15_pro",
        "wait_until": "networkidle"
      }
    ]
  }'
```

How a batch counts against the limits:

| Limit | How a bulk call counts |
| --- | --- |
| Rate limit | A bulk call counts as one request per item. A batch of 25 uses 25 of the minute’s requests. If that many are not left, the call answers `rate_limited` with `Retry-After` and nothing is queued. |
| A batch larger than the limit | It costs the whole minute, and is accepted only when the account has sent nothing else in the last minute or two. |
| Queue | An account may have 100 jobs waiting or rendering for each render of its concurrency, and never fewer than 200. Over that: `queue_limit`, status `429`, nothing queued. |
| Concurrency | The jobs of one account render at most as many at once as its plan’s concurrency. The rest wait. |
| Quota | The batch must fit in what is left of the period. If it does not, the call answers `quota_exceeded` and nothing is queued. |

What is counted against the quota:

| Request | Counted? |
| --- | --- |
| A job that rendered and returned a file | Yes, 1 screenshot |
| A job that failed, with any error | No |
| An item with `"cache": true` answered from a stored copy | No |
| The bulk call itself | No |
| A status call for a batch or a job | No |
| A download of a finished file from its `url` | No |

A list longer than 100 URLs is several calls. Because each item costs one request of the minute, the job still moves at the plan's requests per minute, which is why the model at the top applies unchanged.

More: [bulk screenshots](https://curlshot.com/tools/bulk-screenshots), [the bulk endpoint](https://curlshot.com/docs/bulk), [async jobs and webhooks](https://curlshot.com/docs/async-and-webhooks), [usage and limits](https://curlshot.com/docs/usage-and-limits).

## Glossary

- **Concurrency**: How many renders are running at the same moment. It limits how much work is in progress.
- **Rate limit**: How many requests a service accepts in a window of time, usually a minute. It limits how fast work may start.
- **Queue**: The list of work that is waiting. Items leave it in order as a slot becomes free.
- **Backoff**: Waiting before a retry, and waiting longer before each new one. Exponential backoff doubles the wait: 1, 2, 4 seconds.
- **Idempotent**: Safe to do twice. Handling the same message or URL a second time has the same effect as handling it once.
- **Manifest**: A file that lists every item of a job with what happened to it: the URL, the status, the file name and the error.
- **Webhook**: An address on your server that a service calls when a job ends, so you do not have to keep asking.

## Questions

### How long does it take to screenshot 1,000 URLs?

It depends on three numbers: seconds per render, renders at once, and requests per minute. Time is the larger of URLs x seconds / concurrency and URLs / rate per minute x 60. As an example only, at 4 seconds a render, 5 at once and 60 requests a minute, 1,000 URLs take 16 min 40 s, set by the rate limit. Measure your own seconds per render on 20 URLs first.

### How do I screenshot every page of a website?

Read the site’s sitemap.xml and collect every <loc> value, following sitemap index files to the sitemaps they list. If there is no sitemap, crawl the site from its home page, staying on the same origin. Deduplicate the list, then send it through a queue with limited concurrency.

### Should I raise concurrency or the rate limit to go faster?

Raise the one that is the bottleneck. Requests per minute x seconds per render / 60 is the number of render slots the rate limit can keep busy. If your concurrency is above that number, more concurrency changes nothing and only a higher rate limit helps. If it is below, concurrency is the limit.

### How do I avoid getting blocked when taking many screenshots?

Spread the requests so one host is not hit many times a second, run large jobs off-peak, honour robots.txt and any Retry-After header, and stop when a site answers 429 or 503. For a site you do not own, ask the owner: an allowlist entry prevents the bot checks that otherwise replace some pages with a challenge screen.

### Which errors should a bulk screenshot script retry?

Retry network errors, 429 and 5xx answers, after the wait in Retry-After or a pause that doubles each time, and stop after three or four tries. Do not retry other 4xx answers such as an invalid address or a used-up quota: the same request fails the same way.

### How many URLs can one CurlShot bulk request hold?

Up to 100 requests in one POST, each with its own options, on plans that include bulk; a plan can set a lower number. A longer list is sent as several calls. Each item counts as one request against the per-minute rate limit, and each successful render uses one screenshot of the quota.

### Do failed screenshots count against the quota?

Not with CurlShot: only a successful render uses a screenshot. A failed job is free, and so is an item with cache set to true that is answered from a stored copy. There is a separate cap on how many renders of one account may fail per minute, so fix a request that keeps failing instead of retrying it in a loop.

## Sources

1. sitemaps.org, "Sitemaps XML format (protocol)": https://www.sitemaps.org/protocol.html (checked 11 October 2026)
2. IETF / RFC Editor, "RFC 9309: Robots Exclusion Protocol": https://www.rfc-editor.org/rfc/rfc9309.html (checked 11 October 2026)
3. IETF / RFC Editor, "RFC 9110: HTTP Semantics, section 10.2.3 Retry-After": https://www.rfc-editor.org/rfc/rfc9110.html (checked 11 October 2026)
4. MDN Web Docs, "Retry-After header": https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After (checked 11 October 2026)
5. IETF / RFC Editor, "RFC 6585: Additional HTTP Status Codes, section 4 (429 Too Many Requests)": https://www.rfc-editor.org/rfc/rfc6585.html (checked 11 October 2026)
6. Node.js documentation, "Global objects: fetch": https://nodejs.org/api/globals.html (checked 11 October 2026)

## Go deeper

- [Bulk screenshots: many URLs with one call](https://curlshot.com/tools/bulk-screenshots)
- [Screenshot all pages of a website from its sitemap](https://curlshot.com/tools/screenshot-all-pages-of-a-website)
- [Bulk screenshots from an n8n workflow](https://curlshot.com/tools/n8n-screenshot)
- [Bulk endpoint reference](https://curlshot.com/docs/bulk)
- [Async jobs and webhooks](https://curlshot.com/docs/async-and-webhooks)
- [Quota, rate limit and concurrency](https://curlshot.com/docs/usage-and-limits)

## Read next

- [Screenshot API vs headless browser: the real cost](https://curlshot.com/blog/screenshot-api-vs-headless-browser.md): A cost calculator with every assumption editable, the hidden work behind headless Chrome, and an honest checklist for build or buy.
- [Website change monitoring with screenshots](https://curlshot.com/blog/website-change-monitoring-with-screenshots.md): How to get told when a web page changes: the capture and compare loop, three ways to detect a change, a noise checklist, a cron and Node.js setup, and tools.
- [Website screenshot code examples in 8 languages](https://curlshot.com/blog/screenshot-code-examples.md): The shortest working code to save a full-page screenshot of a website in curl, Node.js, Python, PHP, Go, Ruby, Java and C#, side by side.
