concurrent_limit

The API returns concurrent_limit when a render cannot get a browser in time. RenderScreenshot caps how many browsers run at the same moment. When every browser is busy, the API holds your request for up to 10 seconds for one to free up, then gives up with this error. It is a timing problem, not a request problem: the same request succeeds once a running render finishes. This page is part of the error code reference.

HTTP status 429 Too Many Requests
error.type rate_limit_error
error.retryable true
Credits used 0

Example response

{
  "error": {
    "type": "rate_limit_error",
    "code": "concurrent_limit",
    "message": "Too many renders are running at once. Retry in a few seconds.",
    "retryable": true
  },
  "request_id": "3f1c9a52-7d4e-4b8a-9e21-6c0d5b7a8f13"
}

The response also carries a Retry-After: 5 header. Unlike validation and authentication errors, this one has no docs field.

concurrentlimit or toomany_requests?

Both codes use HTTP 429, so read error.code before you decide how long to wait.

Code What it counts When a retry can succeed
too_many_requests Requests started in the current one-minute window When the window resets (Retry-After seconds)
concurrent_limit Browsers busy at this moment When a running render ends, usually within a few seconds (Retry-After: 5)

Waiting a full minute after concurrent_limit is safe but slow. Wait Retry-After seconds, plus a little jitter.

Common causes

  • A busy moment for the service. The browser cap is shared, so a burst of renders from any source can fill it. A short retry fixes it.
  • Promise.all over a list of URLs. Every capture starts at once and each one asks for a browser at the same moment.
  • Several workers or servers on one account. Count every process that sends requests with your keys, including cron jobs and preview deployments.
  • Slow pages. A page that waits for networkidle or a long wait.timeout holds its browser for longer, so fewer browsers are free for the next request.
  • Retries that add load. A retry loop with no delay re-sends at once and keeps every browser busy.

How to fix it

  1. Cap the number of captures you run at once, across all your processes. The batch concurrency for your plan on the rate limits page is a sensible ceiling.
  2. On concurrent_limit, wait Retry-After seconds plus a little jitter, then retry.
  3. Stop after a few attempts and report the request_id, so a long outage does not turn into an endless loop.
  4. For lists of URLs, consider the batch endpoint. It schedules the renders for you within your plan's batch limits. A batch item that finds no free browser fails with the message Too many renders are running. Retry this URL in a few seconds.

A worker pool that respects the limit

This script runs a fixed number of workers, set by RS_CONCURRENCY. Each worker takes the next URL when it is free, so the number of renders in flight never goes above that number. It still handles concurrent_limit, because the browser cap is shared and can be full when your own pool is not.

// capture-pool.mjs (Node 22+, no dependencies)
const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const headers = { Authorization: `Bearer ${process.env.RS_API_KEY}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function capture(url, attempt = 1) {
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url, preset: 'og_card' }),
  });
  if (res.ok) return Buffer.from(await res.arrayBuffer());

  const body = await res.json().catch(() => null);
  if (body?.error?.code === 'concurrent_limit' && attempt < 5) {
    // A browser frees up when a running render finishes: wait Retry-After, plus jitter.
    const retryAfter = Number(res.headers.get('retry-after')) || 5;
    await sleep(retryAfter * 1000 + Math.random() * 1000);
    return capture(url, attempt + 1);
  }
  throw new Error(`${res.status} ${body?.error?.code}: ${body?.error?.message} (${body?.request_id})`);
}

// Run at most `limit` captures at once. Each worker takes the next URL when it is free.
async function captureAll(urls, limit) {
  const results = new Array(urls.length);
  let next = 0;
  const worker = async () => {
    while (next < urls.length) {
      const i = next++;
      results[i] = await capture(urls[i]).catch((error) => error);
    }
  };
  await Promise.all(Array.from({ length: limit }, worker));
  return results;
}

const limit = Number(process.env.RS_CONCURRENCY ?? 2);
const urls = Array.from({ length: 8 }, (_, i) => `https://example.com/?page=${i}`);
const results = await captureAll(urls, limit);
const failed = results.filter((r) => r instanceof Error);
console.log(`limit ${limit}: ${results.length - failed.length} captured, ${failed.length} failed`);
if (failed.length) process.exitCode = 1;

With the Ruby SDK

The Ruby SDK raises RenderScreenshot::RateLimitError for every 429, and e.code tells the two cases apart:

require 'renderscreenshot'

client = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))
options = RenderScreenshot::TakeOptions.url('https://example.com').preset('og_card')

attempts = 0
begin
  attempts += 1
  image = client.take(options)
rescue RenderScreenshot::RateLimitError => e
  raise if attempts >= 5

  # concurrent_limit clears when a running render ends; too_many_requests at the next minute.
  sleep(e.code == 'concurrent_limit' ? (e.retry_after || 5) + rand : (e.retry_after || 60))
  retry
end
File.binwrite('shot.png', image)

Tested on 2026-10-15 with Node 22.18 and with Ruby 4.0.7 plus renderscreenshot gem 1.0.0, against a local mock that returns this error envelope and enforces a limit of 2. With the pool set 2 above the limit, the retries absorbed every concurrent_limit and all 8 captures succeeded. Not yet run against the live API. Behaviour checked against the API source on 2026-10-18.

Related in this topic

Was this page helpful?