API Rate Limiting and HTTP 429: Definition and Code

Rate limiting caps how many requests a client may send in a time window, and answers the excess with HTTP 429. Algorithms, headers and tested retry code.

By Simon Chiu · Updated

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:

  1. 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.
  2. 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_limit clears 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.
  • 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.

Frequently asked questions

What does HTTP 429 mean?
429 Too Many Requests means the server received more requests from you than it allows in a period of time. The request itself may be fine. Wait, ideally as long as the Retry-After header says, and send it again.
What is the difference between rate limiting and a concurrency limit?
A rate limit counts requests started per time window. A concurrency limit counts requests running at the same moment. Ten slow requests can break a concurrency limit while staying far under a rate limit.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.