Skip to content

API

Captcha Solver Concurrency, 429s and Retry-After

Run many Cloudflare Turnstile tasks at once: how ZeroCaptcha paces calls, what 429, ERROR_RATE_LIMIT and ERROR_NO_SLOT_AVAILABLE mean, and how to back off.

4 min readPublished Updated

A single Turnstile task is easy. A thousand at once raises new questions: how many can run in parallel, what the API does when you send too many, and how your code should slow down without losing work. This guide explains how ZeroCaptcha paces calls, the signals it sends, and a pattern for high-volume pipelines.

What is limited, and what is not

  • createTask has no call budget. What bounds it is your balance, since each task holds its price, and your account’s share of the queue. There is no rate limit on paid tasks as a pricing tier, and no plan to upgrade.
  • The queue is shared fairly. Workers take tasks from the account served least recently, so one account’s burst does not starve another, and each account may have 50 tasks queued or running at once by default. When your share is full, createTask answers ERROR_NO_SLOT_AVAILABLE (queue_full in REST) until one of your tasks ends.
  • Reads have a budget. getTaskResult and getBalance share a budget per key and account. Polling far faster than every second or two is answered with ERROR_RATE_LIMIT (rate_limited in REST) and a Retry-After header.

The “try again later” signals

Signal Where What to do
HTTP 429 Any format Wait for Retry-After, then send the same request
HTTP 5xx Any format Back off and retry; keep the same Idempotency-Key
ERROR_RATE_LIMIT Compatible Poll less often; wait for Retry-After
ERROR_NO_SLOT_AVAILABLE Compatible Wait a few seconds, then retry
ERROR_SERVICE_UNAVAILABLE Compatible Retry with backoff
MAX_USER_TURN, ERROR: 1005 2Captcha Wait the seconds in Retry-After

Retry-After is either a number of seconds or an HTTP date. Honor it when it is present. When it is not, wait one second and double the pause on each retry, up to around 16 seconds.

A bounded worker pool

The simplest way to stay inside limits is to cap how many tasks you have in flight, instead of creating every task at once:

const API = process.env.ZEROCAPTCHA_API;
const KEY = process.env.ZEROCAPTCHA_KEY;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(method, body, headers = {}) {
for (let pause = 1000; ; pause = Math.min(pause * 2, 16000)) {
const reply = await fetch(`${API}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({ clientKey: KEY, ...body }),
});
const retryAfter = Number(reply.headers.get("retry-after"));
if (reply.status === 429 || reply.status >= 500) {
await sleep(retryAfter > 0 ? retryAfter * 1000 : pause);
continue;
}
const json = await reply.json();
const busy = ["ERROR_RATE_LIMIT", "ERROR_NO_SLOT_AVAILABLE", "ERROR_SERVICE_UNAVAILABLE"];
if (json.errorId === 1 && busy.includes(json.errorCode)) {
await sleep(retryAfter > 0 ? retryAfter * 1000 : pause);
continue;
}
return json;
}
}
async function solve(task) {
const created = await call("createTask", { task }, { "Idempotency-Key": crypto.randomUUID() });
if (created.errorId) throw new Error(created.errorCode);
for (;;) {
await sleep(2000);
const result = await call("getTaskResult", { taskId: created.taskId });
if (result.errorId) throw new Error(result.errorCode);
if (result.status === "ready") return result.solution.token;
}
}
// At most 50 tasks in flight.
async function runAll(tasks, limit = 50) {
const results = [];
let next = 0;
await Promise.all(Array.from({ length: limit }, async () => {
while (next < tasks.length) {
const index = next++;
results[index] = await solve(tasks[index]).catch((error) => error);
}
}));
return results;
}

The idempotency key is made once per task and reused on every retry of that createTask, so a retry never makes a second task: see Idempotency keys. Production code should also stop waiting after a deadline, as the quickstart samples do.

Poll less, or not at all

Polling is most of your call volume. Two ways to reduce it:

  • Poll every two seconds, not every hundred milliseconds. A task finishes when it is solved; asking more often does not speed it up.
  • Use callbacks. Name a callbackUrl and the result is posted to you when the task ends. Your read budget is then almost untouched. See Captcha solver callbacks.

Size the pool from real numbers

The status page shows the platform’s median solve time over the last 24 hours. With a median of a few seconds, a pool of 50 keeps many tasks moving per minute. Keep the pool at or under your account’s share, 50 by default: a larger pool only meets ERROR_NO_SLOT_AVAILABLE more often. Your balance is the other bound: each task in flight holds its price, so make sure the available balance covers a full pool.

Tokens still expire

A large pool that solves faster than your forms consume tokens creates expired tokens, which are charged and useless. Solve at the pace you submit. Cloudflare Turnstile token expiry has the details, and the Cloudflare Turnstile solver page shows the flow in other languages.

Questions

Is there a limit on how many tasks I can create?

Your balance and your account's share of the queue bound createTask; there is no call budget on it and no paid tier for volume. Reads such as getTaskResult share a budget per key and account.

How should I wait after a 429?

As long as the Retry-After header says, in seconds or until an HTTP date. Without one, wait a second and double the pause on each retry, up to about 16 seconds.

Does polling faster make tasks finish sooner?

No. A task finishes when it is solved, however often you ask. Poll every one or two seconds, or use a callback.

Read next

This guide is part of the Cloudflare Turnstile solver hub. Every task is charged only when a token is ready.

Get an API key