Skip to content
PlaybookBy 12 min read

Screenshot a list of URLs in bulk, and watch the queue drain

A bulk screenshot job is a queue: URLs wait, a few render at once, and a rate limit decides how fast new ones may start. Set your numbers and run it to see how long the job takes and which limit is holding it back.

A model of your job

Example only

Clock: 0 s of 17 min 11 s1 tile is about 21 URLs

Plays the whole job in 7 seconds. The arithmetic does not need it.

This job takes about

17 min 11 s

Your rate limit, not your concurrency, is what is slowing this job.

Concurrency alone would take
13 min 45 s
The rate limit alone would take
17 min 11 s

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.

At 3% of attempts failing: 30 URLs fail the first time, 31 extra requests are retries (1,031 requests in all), and fewer than 1 still fail after 3 tries.

Limits to try

This is a model, not a benchmark. The starting numbers are examples only, every one is an input you can change, and the formulae are printed below. The plan buttons fill in the real limits of each CurlShot plan.

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. Only one of the two is the bottleneck, and raising the other one changes nothing.

The formulae

  1. requests = URLs x (1 + f + f x f)
  2. time by concurrency = requests x seconds per render / renders at once
  3. time by rate limit = requests / requests per minute x 60
  4. time = the larger of the two
  5. 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 and treats every attempt as taking the average time.

A worked example

Example only

1,000 URLs, 4 seconds per render, 5 at once, 60 requests a minute, 3% of attempts failing.

  1. requests = 1,000 x (1 + 0.03 + 0.03 x 0.03) = 1,031
  2. time by concurrency = 1,031 x 4 / 5 = 825 s = 13 min 45 s
  3. time by rate limit = 1,031 / 60 x 60 = 1,031 s = 17 min 11 s
  4. time = the larger = 17 min 11 s
  5. 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 at other sizes

Each row is the arithmetic above with no failures, so you can check it by hand. The inputs are examples, not measurements of any service.

Time for a bulk screenshot job at example sizes, by concurrency and by rate limit
URLsPer renderAt oncePer minuteTime by concurrencyTime by rate limitJob takesBottleneck
100 URLsPer render4 sAt once1Per minute10Time by concurrency6 min 40 sTime by rate limit10 minJob takes10 minBottleneckRate limit
1,000 URLsPer render4 sAt once2Per minute60Time by concurrency33 min 20 sTime by rate limit16 min 40 sJob takes33 min 20 sBottleneckConcurrency
1,000 URLsPer render4 sAt once5Per minute60Time by concurrency13 min 20 sTime by rate limit16 min 40 sJob takes16 min 40 sBottleneckRate limit
10,000 URLsPer render4 sAt once10Per minute120Time by concurrency1 h 7 minTime by rate limit1 h 23 minJob takes1 h 23 minBottleneckRate limit
10,000 URLsPer render8 sAt once10Per minute120Time by concurrency2 h 13 minTime by rate limit1 h 23 minJob takes2 h 13 minBottleneckConcurrency
50,000 URLsPer render4 sAt once20Per minute600Time by concurrency2 h 47 minTime by rate limit1 h 23 minJob takes2 h 47 minBottleneckConcurrency

Measure the one number nobody can give you

Seconds per render depends on your pages, not on a formula: a light landing page and a long page full of images are several times apart. Time 20 URLs from your own list, take the average, and put it in the model before you plan the whole job.

Where does the list of URLs come from?

A list of URLs for bulk screenshots comes from one of three places: a spreadsheet you already have, the site's sitemap, or a crawl of the site. All three end as the same file: urls.txt, one address per line.

  1. 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.
    Shell
    # 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
  2. 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.[1]
    • 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.
    sitemap.mjs
    // 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'))
  3. 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.[2]
    • 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.
    crawl.mjs
    // 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 ([email protected])' } })
      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

A bulk screenshot job has seven stages, and each one exists because the job is too long to watch. A URL enters on the left and leaves as a file and a row in a manifest.

  1. 1List

    One URL per line, from a CSV, a sitemap or a crawl.

  2. 2Normalise

    Drop fragments and tracking parameters, then remove duplicates.

  3. 3Queue

    Every URL waits its turn. Nothing starts all at once.

  4. 4Render

    A fixed number run at the same moment, spaced to fit the rate limit.

  5. 5Retry

    A 429 or 5xx waits, then tries again. The wait doubles each time.

  6. 6Store

    Each file gets a name you can compute again from its URL.

  7. 7Manifest

    One row per URL: url, status, file, error.

The seven stages of a bulk screenshot job. Failures loop back from Retry to Render; everything else moves one way.

Normalise before you queue

Normalising the list is the cheapest stage of a bulk screenshot job, because every duplicate it removes is a render nobody pays for. The same page often appears several times: with and without a fragment, with a tracking parameter from a newsletter, with its query parameters in another order.

The function on the right makes those spellings identical, so a Set can drop the copies. It keeps every other query parameter, because ?page=2 is a different page.

Normalise and deduplicate
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

This Node.js script screenshots every URL in urls.txt with a fixed number of requests at once, a per-minute limit, retries with backoff and a results file. It uses only the standard library and needs Node.js 18 or later, where fetch is built in.[6]

bulk.mjs
// 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.`)

What each part of the script 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.[3][4]
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.

What was tested

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.

The script calls the CurlShot screenshot endpoint and reads its key from the SCREENSHOT_ACCESS_KEY environment variable. For another service, change the API line and the header: the queue, the rate limit, the retries and the manifest stay the same.

How do you take thousands of screenshots without getting blocked?

Capturing thousands of pages of someone else's site is load on their server, and sites block what looks like load. The practice that avoids it is short.

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.

  1. 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.

  2. 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.

  3. 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.[2]

  4. 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.[2]

  5. 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.[5][2]

  6. 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.

How to name and store bulk screenshot files

A bulk screenshot file needs a name that is safe on every file system, unique per URL and possible to compute again from the URL alone. A slug plus a short hash gives all three.

Naming schemes for bulk screenshot files, with what each is good and bad at
SchemeExampleGoodBad
Slug of the URLExampleexample-com-pricing.pngGoodReadable. You can find a page by eye.Bad/a-b and /a/b become the same name. Long addresses need cutting, and query strings make it worse.
Hash of the URLExample23a538fde85c5907.pngGoodFixed length, safe on every system, unique per URL, and you can compute it again later.BadMeans nothing to a person. Useless without the manifest.
Slug plus short hashExampleexample-com-pricing-23a538fd.pngGoodReadable and unique. This is what the script above writes.BadSlightly longer names. Still changes when the URL changes by one character.
Date foldersExample2026-10-28/example-com-pricing-23a538fd.pngGoodKeeps each run apart, so you can compare a page across days and delete a whole run at once.BadStorage grows with every run. Decide how many runs to keep before the first one.

Why a manifest beats a clever file name

A manifest is a file with one row per URL that records what happened to it. A file name can hold one fact about a screenshot. The manifest holds all of them: the exact URL, whether it worked, where the file is and why it failed.

It is also what makes a long job safe to interrupt. To continue, read the manifest, keep the rows that say ok, and queue only the rest.

results.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?

Six things fail in almost every large screenshot job, and two of them do not look like failures: the row says the capture worked, and the file shows the wrong page.

Common failures in a bulk screenshot job, how each shows up in the results, and what to do
ProblemHow it shows up in the resultsWhat to do
Timeouts on slow pagesHow it shows up in the resultsA failed row with timeout (status 504). The same few URLs fail on every run.What to doRetry 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 hostsHow it shows up in the resultsOften a row that says ok: the screenshot is a challenge or "access denied" page. Look for files that are unusually small or all alike.What to doSlow 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 loginHow it shows up in the resultsA row that says ok and a screenshot of a sign-in form. Many pages produce the identical file.What to doTake those URLs out of the public list and capture them in a separate, signed-in job.
Duplicate URLs with tracking parametersHow it shows up in the resultsTwo rows, two files, one page: ?utm_source=... and the plain address. Quota spent twice.What to doNormalise before queuing: drop fragments and tracking parameters, sort the rest, then deduplicate.
Pages that never go idleHow it shows up in the resultstimeout on pages that look loaded: a chat widget, a video or analytics keeps the network busy, so wait_until=networkidle never arrives.What to doUse 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-jobHow it shows up in the resultsEvery row after a certain point fails with quota_exceeded (status 402).What to doDo 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

The CurlShot bulk endpoint starts up to 100 screenshots with one POST and renders them in the background, so the queue, the retries of temporary errors and the waiting are done on our side. This is how it behaves.

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.
One bulk call: three pages, three sets of options
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"
      }
    ]
  }'

A list longer than 100 URLs is several calls. Each item costs one request of the minute, so the job still moves at your plan's requests per minute, and the model at the top of this page applies unchanged.

How a batch counts against the limits

How a bulk call counts against the rate limit, queue, concurrency and quota
LimitHow a bulk call counts
Rate limitHow a bulk call countsA 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 limitHow a bulk call countsIt costs the whole minute, and is accepted only when the account has sent nothing else in the last minute or two.
QueueHow a bulk call countsAn 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.
ConcurrencyHow a bulk call countsThe jobs of one account render at most as many at once as its plan’s concurrency. The rest wait.
QuotaHow a bulk call countsThe batch must fit in what is left of the period. If it does not, the call answers quota_exceeded and nothing is queued.

What uses a screenshot of the quota

  • A job that rendered and returned a fileCounts as 1
  • A job that failed, with any errorFree
  • An item with "cache": true answered from a stored copyFree
  • The bulk call itselfFree
  • A status call for a batch or a jobFree
  • A download of a finished file from its urlFree

Start with the bulk screenshots page, read every field in the bulk endpoint reference, and see async jobs and webhooks for the signature check on each delivery. The numbers of each plan are in usage and limits.

Bulk screenshot 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 people ask

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. [1]sitemaps.org, Sitemaps XML format (protocol). Checked .
  2. [2]IETF / RFC Editor, RFC 9309: Robots Exclusion Protocol. Checked .
  3. [3]IETF / RFC Editor, RFC 9110: HTTP Semantics, section 10.2.3 Retry-After. Checked .
  4. [4]MDN Web Docs, Retry-After header. Checked .
  5. [5]IETF / RFC Editor, RFC 6585: Additional HTTP Status Codes, section 4 (429 Too Many Requests). Checked .
  6. [6]Node.js documentation, Global objects: fetch. Checked .

Screenshot any page in one step

Paste an address into CurlShot and download the screenshot. A free account gives you 100 screenshots every month. No card needed.