concurrent_limit

The API returns concurrent_limit when you start a render while your plan's maximum number of renders is already in progress. It is a timing problem, not a request problem: the same request succeeds as soon as one of your running renders 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 concurrent requests. Maximum is 5.",
    "retryable": true,
    "docs": "https://docs.renderscreenshot.com/errors/concurrent_limit"
  },
  "request_id": "req_abc123"
}

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 Renders running at this moment When any one of your running renders ends, often within a few seconds

Waiting a full minute after concurrent_limit is safe but slow. A short wait with jitter is enough.

Common causes

  • Promise.all over a list of URLs. Every capture starts at once. Ten URLs on a plan with a lower limit means some of them fail at once.
  • Several workers or servers on one account. The limit comes from your plan, so 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 slot for longer, so the same request rate needs more slots.
  • Retries that add load. A retry loop with no delay re-sends at once and keeps every slot full.

How to fix it

  1. Read your limit from GET /v1/usage (rate_limits.concurrent_limit), or from the rate limits page.
  2. Run at most that many captures at once, across all your processes.
  3. On concurrent_limit, wait a short, random time and retry. Use Retry-After if the response has it.
  4. For lists of URLs, consider the batch endpoint. It schedules the renders for you within your plan's batch limits.

A worker pool that respects the limit

This script asks the API for your limit, then runs that many workers. Each worker takes the next URL when it is free, so the number of renders in flight never goes above the limit. It still handles concurrent_limit, because another process on the same account can take a slot.

// 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));

// Ask the API how many renders your plan may run at the same time.
async function concurrentLimit() {
  const res = await fetch(`${API}/v1/usage`, { headers });
  if (!res.ok) throw new Error(`GET /v1/usage failed with HTTP ${res.status}`);
  const usage = await res.json();
  return usage.rate_limits?.concurrent_limit ?? 1;
}

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 slot frees up when one of your renders finishes: wait briefly, with jitter.
    const retryAfter = Number(res.headers.get('retry-after'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : Math.random() * 500 * attempt);
    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 = await concurrentLimit();
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 one of your renders ends; too_many_requests at the next minute.
  sleep(e.code == 'concurrent_limit' ? rand(0.5..2.0) : (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 sized from /v1/usage, no request hit the limit. With the pool deliberately set 2 above the limit, the retries absorbed every concurrent_limit and all 8 captures succeeded.

Related in this topic

Was this page helpful?