Rate limiting is a server rule that caps how many requests one client (a key, a user or an IP address) may send within a time window, and rejects the excess, usually with HTTP status 429 Too Many Requests and a Retry-After header that says when to try again.
It protects a service from overload and from one client using everyone's capacity. This entry is part of the screenshot glossary.
What it means in plain terms
Think of a turnstile that lets ten people through per minute. The eleventh person is not banned, only told to come back at the start of the next minute. A 429 is that message. It is temporary, and a client that waits and retries gets through.
Screenshot work meets rate limits in two directions:
- The sites you capture limit you. Capture 500 pages from one shop in a tight loop and its server or CDN starts to answer 429, or a challenge page. Your screenshot then shows "Too Many Requests" instead of the product.
- The API you call limits you. A screenshot API counts your requests per key. RenderScreenshot uses a fixed one-minute window per API key; the numbers per plan are on the rate limits reference.
How it works technically
Servers count requests with one of a few algorithms:
| Algorithm | How it counts | Behaviour at the edge |
|---|---|---|
| Fixed window | A counter per clock window (for example per minute), reset at the boundary | Simple and predictable. A client can send a full window at 0:59 and another at 1:00. |
| Sliding window | Requests in the last N seconds, measured from now | Smooth, no burst at the boundary. Costs more to track. |
| Token bucket | A bucket refills at a fixed rate; each request takes a token | Allows short bursts up to the bucket size, then the steady rate. |
| Leaky bucket | Requests queue and drain at a fixed rate | Smooths traffic instead of rejecting it, until the queue is full. |
sequenceDiagram participant C as Client participant S as Server (limit 3 per window) C->>S: request 1, 2, 3 S-->>C: 200, 200, 200 C->>S: request 4 S-->>C: 429, Retry-After: 1 Note over C: wait 1 s + jitter C->>S: request 4 again (new window) S-->>C: 200
Status 429 is defined in RFC 6585. Retry-After comes from the HTTP semantics spec (RFC 9110) and holds either seconds or a date. Many APIs add their own headers; RenderScreenshot sends X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and adds Retry-After on a 429.
How to handle it
Puppeteer: a target site answers 429
page.goto returns the main response, so you can see the status before you capture. Never screenshot a 429 page; wait and load again:
import puppeteer from 'puppeteer'; const BASE = process.env.TARGET ?? 'https://example.com'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const browser = await puppeteer.launch(); try { const page = await browser.newPage(); for (let i = 1; i <= 6; i++) { for (let attempt = 1; ; attempt++) { const res = await page.goto(`${BASE}/?item=${i}`, { waitUntil: 'load' }); if (res?.status() !== 429) { if (!res?.ok()) throw new Error(`item ${i}: HTTP ${res?.status()}`); await page.screenshot({ path: `item-${i}.png` }); break; } if (attempt === 4) throw new Error(`item ${i}: still 429 after ${attempt} attempts`); const seconds = Number(res.headers()['retry-after']) || 2 ** attempt; // header, else backoff console.log(`item ${i}: 429, waiting ${seconds}s`); await sleep(seconds * 1000 + Math.random() * 250); } } } finally { await browser.close(); }
Number() handles the seconds form of Retry-After. A date value becomes NaN and falls back to the backoff.
Playwright: stay under the limit
Better than recovering from 429 is never causing it. Space the loads for each host:
import { chromium } from 'playwright'; const BASE = process.env.TARGET ?? 'https://example.com'; const GAP_MS = 700; // the test site allowed 3 loads per 2 s, so at least 667 ms apart let nextAt = 0; const browser = await chromium.launch(); try { const page = await browser.newPage(); for (let i = 1; i <= 6; i++) { const wait = nextAt - Date.now(); if (wait > 0) await new Promise((r) => setTimeout(r, wait)); // pacing, not a readiness check nextAt = Date.now() + GAP_MS; const res = await page.goto(`${BASE}/?item=${i}`); if (res?.status() === 429) throw new Error(`item ${i}: 429, Retry-After ${res.headers()['retry-after']}`); await page.screenshot({ path: `pw-item-${i}.png` }); } } finally { await browser.close(); }
Tested on 2026-10-15 with Node 22.18.0, [email protected] and [email protected] against a local test server that allows 3 page loads per 2-second fixed window. The Puppeteer script got one 429, waited the Retry-After time (1 to 2 seconds across our runs) and saved all six captures. The Playwright script, pacing at 700 ms, got no 429 at all. Both also ran against example.com.
RenderScreenshot: the API answers 429
The Node.js SDK raises a RenderScreenshotError with httpStatus 429 and retryAfter in seconds. Wait that long and retry the same request:
import { Client, TakeOptions, RenderScreenshotError } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); async function take(options, attempts = 3) { try { return await client.take(options); } catch (err) { if (!(err instanceof RenderScreenshotError) || err.httpStatus !== 429 || attempts <= 1) throw err; await new Promise((r) => setTimeout(r, ((err.retryAfter ?? 60) + Math.random()) * 1000)); return take(options, attempts - 1); } } const image = await take(TakeOptions.url('https://example.com').preset('og_card'));
A rejected request uses no credit. Two habits keep you clear of the limit: send lists as one batch request, which counts once, and embed the X-Cache-URL CDN link in your pages instead of calling the API on every view. The toomanyrequests reference has the full response and a Python version.
Common mistakes
- Retrying at once. A loop with no wait spends the next window too.
- Capturing the error page. Check the status before
page.screenshot, or your archive fills with "Too Many Requests" images. - Same backoff on every worker. Add jitter, or all workers retry in the same second.
- Confusing the two limits.
concurrent_limitclears when one render finishes; see concurrency limit. - Ignoring soft limits. Some sites never send 429; they serve a challenge. That is bot detection, not rate limiting.
Related terms
- Concurrency limit: renders in flight, not requests per minute.
- Bot detection: what a site does when it decides you are not a person.
- Webhook: replaces status polling, which uses up a rate limit.
- Cache TTL: how long a CDN link can stand in for an API call.
To see the rate limit headers on your own key, get a free API key and inspect the response of one request.