An API concurrency limit is the maximum number of requests from one client that a service will process at the same moment; a new request over that number is rejected or queued until one of the running requests finishes.
It is not the same as a rate limit, and confusing the two is the most common cause of bursts of 429 errors in screenshot jobs. This entry is part of the screenshot glossary.
What it means in plain terms
Think of a fixed number of slots. Each request takes a slot when it starts and gives it back when it ends. A rate limit asks "how many did you start this minute?". A concurrency limit asks "how many are open right now?".
For fast APIs, the difference hardly matters. Screenshots are slow: a capture loads a whole web page, waits for it to settle and encodes an image. A render that holds its slot for several seconds means a few slots fill up long before you get near a per-minute limit.
How the numbers relate
The link between the two limits is Little's law: requests in flight = arrival rate × time per request.
flowchart LR Q["Your queue<br/>(URLs waiting)"] --> P{"Free slot?"} P -->|yes| S1["Slot 1: rendering"] P -->|yes| S2["Slot 2: rendering"] P -->|yes| S3["Slot 3: rendering"] P -->|"no: wait, or 429 if you send anyway"| Q S1 --> D[Done, slot freed] S2 --> D S3 --> D
So the most a pool can finish per minute is slots × 60 / seconds per render. A worked table, for illustration only:
| Slots | Seconds per render | Most renders per minute from slots | Binding limit if the rate limit is 60/min |
|---|---|---|---|
| 2 | 4 | 30 | Concurrency |
| 5 | 4 | 75 | Rate (60) |
| 5 | 8 | 37 | Concurrency |
| 10 | 3 | 200 | Rate (60) |
Whichever number is lower is your real ceiling. A slow page (long waits, full-page captures) halves the throughput of every slot, so measure your own render times before you size anything.
Concurrency in Puppeteer, Playwright and RenderScreenshot
When you run the browser yourself, the concurrency limit is one you set: how many pages you open at once in one Chrome. Too many, and the browser runs out of memory. Here is a pool that never has more than LIMIT pages open:
import puppeteer from 'puppeteer'; const LIMIT = 3; // pages open at the same moment const urls = Array.from({ length: 8 }, (_, i) => `https://example.com/?n=${i}`); const browser = await puppeteer.launch(); let inFlight = 0; let peak = 0; try { let next = 0; const worker = async () => { while (next < urls.length) { const url = urls[next++]; peak = Math.max(peak, ++inFlight); const page = await browser.newPage(); try { await page.goto(url); await page.screenshot({ path: `shot-${new URL(url).searchParams.get('n')}.png` }); } finally { await page.close(); inFlight--; } } }; await Promise.all(Array.from({ length: LIMIT }, worker)); console.log(`captured ${urls.length} pages, peak in flight: ${peak}`); } finally { await browser.close(); }
It printed captured 8 pages, peak in flight: 3. Promise.all(urls.map(capture)) would have opened all 8 at once.
In Playwright, the same pool works with a fresh browser context per job, which keeps cookies and storage apart:
import { chromium } from 'playwright'; const LIMIT = 3; const queue = Array.from({ length: 8 }, (_, i) => `https://example.com/?n=${i}`); const browser = await chromium.launch(); try { const worker = async () => { for (let url = queue.shift(); url; url = queue.shift()) { const context = await browser.newContext(); try { const page = await context.newPage(); await page.goto(url); await page.screenshot({ path: `pw-${new URL(url).searchParams.get('n')}.png` }); } finally { await context.close(); } } }; await Promise.all(Array.from({ length: LIMIT }, worker)); } finally { await browser.close(); }
With RenderScreenshot the browser capacity is ours to manage, and your plan sets the limits. Two documented mechanisms apply:
- Single requests. If you start more renders than your plan allows at once, the API answers HTTP 429 with the code
concurrent_limit. That page has a worker pool sized fromGET /v1/usage. - Batches.
POST /v1/batchtakes the whole list and renders a plan-dependent number of URLs in parallel; the rest wait in a queue. That is not an error, it just takes longer.
curl -X POST https://api.renderscreenshot.com/v1/batch \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"urls": ["https://example.com/?n=1", "https://example.com/?n=2", "https://example.com/?n=3"], "options": {"preset": "og_card"}}'
The per-plan numbers are on the rate limits page, and the batch endpoint documents the request. The CLI has the same idea as a flag: rs batch --concurrency <n>.
Tested on 2026-10-15 on macOS 27 (arm64) with Node 22.18.0, [email protected] (Chrome 148.0.7778.97, run with headless: 'shell' on this machine) and [email protected]. The batch request was not run for this page.
Common mistakes
Promise.allover the whole list. Every request starts at once. Use a pool, a queue with a concurrency setting, or the batch endpoint.- Counting one process only. The API's limit covers your whole account or key. Two servers with three workers each are six requests in flight.
- Retrying at once. A retry without a delay takes the slot back immediately and keeps the pool full. Wait a short, random time; a slot frees up when any render ends.
- Treating every 429 the same. A rate limit clears at the next window; a concurrency limit clears when a request finishes. Read the error code.
Related terms
- Rate limiting
- Browser context
- Webhook, to avoid polling batch status
- Zombie processes, what a leaky self-hosted pool leaves behind
For self-hosted pools at scale, read scaling Puppeteer screenshots. To hand the pool to us, get a free API key.