Duas causas respondem pela maioria das filas de CAPTCHA que travam em produção: maxConcurrent configurado acima das threads contratadas no plano da CaptchaAI, ou o padrão de fila errado para o volume do job. O Node.js lida bem com essa carga porque, mesmo single-threaded, seu loop de eventos foi feito justamente para isso — enviar a requisição, liberar a thread e retomar quando a resposta chega. Este guia mostra cinco padrões de fila para resolução de CAPTCHA, do Promise.allSettled mais simples a um sistema com prioridade, retentativa e fila de mensagens mortas pronto para produção.
Qual padrão de fila escolher
- Promise.allSettled — lotes pontuais de algumas dezenas de tarefas, sem classe dedicada.
- ConcurrencyQueue — trava
maxConcurrentnum valor fixo e reaproveita o mesmo limitador em vários pontos do código. - CaptchaQueue (EventEmitter) — um painel interno precisa acompanhar
submitted/solved/failedem tempo real. - PriorityCaptchaQueue — desafios de checkout ou login não podem esperar atrás de tarefas de baixa prioridade.
- RetryQueue — falhas transitórias não podem derrubar o lote inteiro; erros persistentes vão para a fila de mensagens mortas.
Lote simples com Promise.allSettled
Para um script pontual — resolver o CAPTCHA de cada linha de uma planilha, por exemplo — não vale a pena montar uma classe de fila. Promise.allSettled dispara tudo de uma vez e devolve o resultado de cada tarefa, sucesso ou falha, sem que uma rejeição derrube as demais:
const API_KEY = "YOUR_API_KEY";
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function solveSingle(method, params) {
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams({ key: API_KEY, method, json: "1", ...params }),
});
const submitData = await submitResp.json();
if (submitData.status !== 1) throw new Error(submitData.request);
const taskId = submitData.request;
for (let i = 0; i < 30; i++) {
await sleep(5000);
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: API_KEY,
action: "get",
id: taskId,
json: "1",
})}`
);
const data = await pollResp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") throw new Error("Unsolvable");
}
throw new Error("Timed out");
}
// Solve all at once
async function solveBatch(tasks) {
const results = await Promise.allSettled(
tasks.map((task) => solveSingle(task.method, task.params))
);
return results.map((result, i) => ({
taskId: tasks[i].id,
status: result.status,
value: result.status === "fulfilled" ? result.value : null,
error: result.status === "rejected" ? result.reason.message : null,
}));
}
// Usage
const tasks = Array.from({ length: 10 }, (_, i) => ({
id: i,
method: "userrecaptcha",
params: { googlekey: `KEY_${i}`, pageurl: `https://example.com/${i}` },
}));
const results = await solveBatch(tasks);
console.log(`Solved: ${results.filter((r) => r.status === "fulfilled").length}/10`);
Essa abordagem funciona até algumas dezenas de tarefas. Acima disso, a API passa a devolver ERROR_NO_SLOT_AVAILABLE — hora de migrar para uma fila com limite de simultaneidade.
Fila com limite de simultaneidade
O ConcurrencyQueue resolve isso: você define quantas soluções rodam em paralelo e o restante espera na fila até liberar uma vaga.
class ConcurrencyQueue {
constructor(maxConcurrent = 5) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.queue = [];
this.results = [];
}
add(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
this.#process();
});
}
async #process() {
if (this.running >= this.maxConcurrent || this.queue.length === 0) return;
this.running++;
const { fn, resolve, reject } = this.queue.shift();
try {
const result = await fn();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.running--;
this.#process();
}
}
async addBatch(fns) {
return Promise.allSettled(fns.map((fn) => this.add(fn)));
}
}
// Usage
const queue = new ConcurrencyQueue(5);
const tasks = Array.from({ length: 20 }, (_, i) => () =>
solveSingle("userrecaptcha", {
googlekey: `KEY_${i}`,
pageurl: `https://example.com/${i}`,
})
);
const results = await queue.addBatch(tasks);
const solved = results.filter((r) => r.status === "fulfilled");
console.log(`Solved: ${solved.length}/${results.length}`);
Ajuste maxConcurrent de acordo com as threads do plano — BASIC (US$ 15/mês, 5 threads) ou STANDARD (US$ 30/mês, 15 threads), por exemplo. Nunca iguale maxConcurrent ao total de threads contratadas; deixe folga para retentativas.
Fila com EventEmitter para acompanhar o progresso
Quando um painel interno precisa ver o andamento em tempo real, troque o retorno simples por eventos:
const { EventEmitter } = require("events");
class CaptchaQueue extends EventEmitter {
#apiKey;
#maxConcurrent;
#pending;
#active;
constructor(apiKey, maxConcurrent = 5) {
super();
this.#apiKey = apiKey;
this.#maxConcurrent = maxConcurrent;
this.#pending = [];
this.#active = 0;
this.stats = { submitted: 0, solved: 0, failed: 0 };
}
submit(id, method, params) {
this.#pending.push({ id, method, params });
this.stats.submitted++;
this.emit("submitted", { id, total: this.stats.submitted });
this.#drain();
}
async #drain() {
while (this.#active < this.#maxConcurrent && this.#pending.length > 0) {
const task = this.#pending.shift();
this.#active++;
this.#solve(task).finally(() => {
this.#active--;
this.#drain();
if (this.#active === 0 && this.#pending.length === 0) {
this.emit("complete", this.stats);
}
});
}
}
async #solve(task) {
try {
const token = await solveSingle(task.method, task.params);
this.stats.solved++;
this.emit("solved", { id: task.id, token, stats: { ...this.stats } });
} catch (error) {
this.stats.failed++;
this.emit("failed", { id: task.id, error: error.message, stats: { ...this.stats } });
}
}
}
// Usage
const queue = new CaptchaQueue("YOUR_API_KEY", 5);
queue.on("submitted", ({ id, total }) => {
console.log(`Submitted #${id} (total: ${total})`);
});
queue.on("solved", ({ id, stats }) => {
console.log(`Solved #${id} — ${stats.solved}/${stats.submitted}`);
});
queue.on("failed", ({ id, error }) => {
console.log(`Failed #${id}: ${error}`);
});
queue.on("complete", (stats) => {
const rate = ((stats.solved / stats.submitted) * 100).toFixed(1);
console.log(`Done: ${stats.solved}/${stats.submitted} (${rate}%)`);
});
// Submit tasks
for (let i = 0; i < 15; i++) {
queue.submit(i, "userrecaptcha", {
googlekey: `KEY_${i}`,
pageurl: `https://example.com/${i}`,
});
}
Os eventos submitted, solved, failed e complete dão a visibilidade que faltava na versão anterior, sem esperar o lote inteiro terminar.
Fila com prioridade: checkout na frente da coleta de dados
Nem toda tarefa tem a mesma urgência: um checkout com Cloudflare Turnstile travado atrás de 40 tarefas de coleta de dados é uma venda perdida. A PriorityCaptchaQueue ordena a fila por prioridade antes de despachar:
class PriorityQueue {
#items = [];
enqueue(item, priority) {
this.#items.push({ item, priority });
this.#items.sort((a, b) => a.priority - b.priority);
}
dequeue() {
return this.#items.shift()?.item;
}
get length() {
return this.#items.length;
}
}
class PriorityCaptchaQueue {
#apiKey;
#maxConcurrent;
#queue;
#active;
#results;
constructor(apiKey, maxConcurrent = 5) {
this.#apiKey = apiKey;
this.#maxConcurrent = maxConcurrent;
this.#queue = new PriorityQueue();
this.#active = 0;
this.#results = new Map();
}
submit(id, method, params, priority = 5) {
return new Promise((resolve, reject) => {
this.#queue.enqueue({ id, method, params, resolve, reject }, priority);
this.#drain();
});
}
async #drain() {
while (this.#active < this.#maxConcurrent && this.#queue.length > 0) {
const task = this.#queue.dequeue();
this.#active++;
solveSingle(task.method, task.params)
.then((token) => {
this.#results.set(task.id, { status: "solved", token });
task.resolve(token);
})
.catch((err) => {
this.#results.set(task.id, { status: "error", error: err.message });
task.reject(err);
})
.finally(() => {
this.#active--;
this.#drain();
});
}
}
}
// Usage: high-priority checkout, low-priority scraping
const pq = new PriorityCaptchaQueue("YOUR_API_KEY", 3);
// Priority 1 (highest) — checkout
const checkoutToken = pq.submit(
"checkout_1",
"turnstile",
{ sitekey: "KEY", pageurl: "https://shop.com/checkout" },
1
);
// Priority 5 (normal) — product scraping
for (let i = 0; i < 5; i++) {
pq.submit(
`product_${i}`,
"userrecaptcha",
{ googlekey: "KEY", pageurl: `https://shop.com/p/${i}` },
5
);
}
Prioridade 1 fura a fila; prioridade 5 (padrão) espera atrás dela. Use esse padrão sempre que checkout e login dividirem workers com automação de baixa prioridade.
Fila de retentativa com fila de mensagens mortas
Falhas transitórias — timeout de rede, um ERROR_NO_SLOT_AVAILABLE momentâneo — não deveriam derrubar o lote inteiro. A RetryQueue tenta de novo automaticamente e só desiste depois de maxRetries, movendo o resto para a fila de mensagens mortas:
class RetryQueue {
#apiKey;
#maxRetries;
#results;
#deadLetter;
constructor(apiKey, maxRetries = 3) {
this.#apiKey = apiKey;
this.#maxRetries = maxRetries;
this.#results = [];
this.#deadLetter = [];
}
async processBatch(tasks, maxConcurrent = 5) {
const queue = tasks.map((t) => ({ ...t, attempts: 0 }));
while (queue.length > 0) {
const batch = queue.splice(0, maxConcurrent);
const results = await Promise.allSettled(
batch.map((task) => this.#solveWithRetry(task))
);
for (let i = 0; i < results.length; i++) {
const result = results[i];
const task = batch[i];
if (result.status === "fulfilled") {
this.#results.push({ id: task.id, token: result.value });
} else {
task.attempts++;
if (task.attempts < this.#maxRetries) {
queue.push(task); // Retry
console.log(`Retry ${task.attempts}/${this.#maxRetries}: ${task.id}`);
} else {
this.#deadLetter.push({
id: task.id,
error: result.reason.message,
attempts: task.attempts,
});
}
}
}
}
return {
solved: this.#results,
failed: this.#deadLetter,
};
}
async #solveWithRetry(task) {
return solveSingle(task.method, task.params);
}
}
Depois de investigar a fila de mensagens mortas, reenvie as tarefas corrigidas num lote novo — não misture retentativa automática com correção manual de parâmetros.
Painel de monitoramento da fila
Para decidir se vale subir ou reduzir maxConcurrent, você precisa de números, não de intuição. O QueueMonitor acumula throughput, tempo médio de resolução e taxa de sucesso a cada lote processado:
class QueueMonitor {
#startTime;
#solveTimes;
constructor() {
this.#startTime = Date.now();
this.#solveTimes = [];
this.counts = { submitted: 0, solving: 0, solved: 0, failed: 0 };
}
recordSubmit() {
this.counts.submitted++;
this.counts.solving++;
}
recordSolved(solveTime) {
this.counts.solving--;
this.counts.solved++;
this.#solveTimes.push(solveTime);
}
recordFailed() {
this.counts.solving--;
this.counts.failed++;
}
report() {
const elapsed = (Date.now() - this.#startTime) / 1000;
const avgTime =
this.#solveTimes.length > 0
? this.#solveTimes.reduce((a, b) => a + b, 0) / this.#solveTimes.length
: 0;
const throughput = this.counts.solved / (elapsed / 60);
const successRate =
this.counts.solved + this.counts.failed > 0
? (this.counts.solved / (this.counts.solved + this.counts.failed)) * 100
: 0;
return {
elapsed: `${elapsed.toFixed(0)}s`,
submitted: this.counts.submitted,
solving: this.counts.solving,
solved: this.counts.solved,
failed: this.counts.failed,
avgSolveTime: `${(avgTime / 1000).toFixed(1)}s`,
throughput: `${throughput.toFixed(1)}/min`,
successRate: `${successRate.toFixed(1)}%`,
};
}
}
Erros comuns na fila de CAPTCHA e como corrigir
| Sintoma | Causa | Correção |
|---|---|---|
| Todas as promises são rejeitadas ao mesmo tempo | Limite de threads do plano foi atingido | Reduza maxConcurrent |
| Uso de memória cresce com o tempo | Resultados se acumulando sem limpeza | Processe e limpe os resultados periodicamente |
| A fila esvazia, mas as tarefas continuam pendentes | Falta a chamada de #drain() após a tarefa terminar |
Confira o gatilho de drenagem no bloco finally |
ERROR_NO_SLOT_AVAILABLE |
Chamadas simultâneas acima do plano contratado | Adicione atraso entre envios ou reduza maxConcurrent |
| Fila de mensagens mortas cresce sem parar | Erros persistentes, não transitórios | Verifique o parâmetro (sitekey, pageurl) — raramente é instabilidade de rede |
Rodando a fila perto da CaptchaAI: latência e LGPD
Se os workers rodam num datacenter distante do endpoint da API, o RTT entra na conta: cada consulta de status soma milissegundos por tentativa, e isso se multiplica com maxConcurrent alto. Hospedar os workers numa região como AWS sa-east-1 (São Paulo) reduz esse trecho quando o resto da infraestrutura já está no Brasil — a requisição ainda cruza a internet até a CaptchaAI, mas a origem fica mais previsível.
Se a fila registra pageurl em log, trate isso como dado potencialmente sensível: e-mails ou tokens de sessão às vezes vazam para dentro da URL de páginas de checkout ou login. Considere as obrigações da LGPD ao decidir o que fica em log e por quanto tempo, principalmente em staging com dados reais.
Perguntas frequentes
Quantas threads da CaptchaAI eu preciso para maxConcurrent de 20?
BASIC (US$ 15/mês, 5 threads) e STANDARD (US$ 30/mês, 15 threads) não cobrem. O ADVANCE (US$ 90/mês, 50 threads) já dá folga. Nunca configure maxConcurrent igual ao total de threads contratadas.
Vale a pena usar Redis e BullMQ desde o primeiro dia, ou só depois?
Para volume baixo, os padrões deste guia resolvem sem dependência extra. Migre para bull ou bullmq quando precisar de fila persistente entre reinícios ou de workers em mais de um servidor.
O ConcurrencyQueue funciona misturando reCAPTCHA, Turnstile e outros tipos na mesma fila?
Sim. A fila só controla quantas chamadas a solveSingle() rodam ao mesmo tempo — pode enfileirar userrecaptcha e turnstile lado a lado, desde que method e parâmetros estejam corretos.
O que fazer quando recebo muitos ERROR_NO_SLOT_AVAILABLE?
É o sinal de que maxConcurrent ultrapassou as threads do plano. Reduza maxConcurrent; se o throughput ainda não bastar, considere um plano com mais threads, como ADVANCE, PREMIUM ou CORPORATE.
Como faço log dos parâmetros da fila sem violar a LGPD?
Evite gravar o pageurl completo quando ele pode conter identificadores de usuário — grave só o domínio ou um ID de tarefa interno, com retenção curta.
Resumo
Não existe "a" fila certa para resolver CAPTCHA em Node.js — existe a fila certa para o seu volume e o seu plano. Comece com Promise.allSettled para lotes pontuais, suba para ConcurrencyQueue para travar maxConcurrent nas threads contratadas, use EventEmitter para acompanhar o progresso ao vivo, priorize checkout à frente de coleta de dados e feche o ciclo com retentativa e fila de mensagens mortas — todos chamando a mesma solveSingle(), integrada com a CaptchaAI.