How to Solve CAPTCHAs in a BullMQ Worker (Node.js)

bullmq captcha - How to Solve CAPTCHAs in a BullMQ Worker (Node.js)

A BullMQ captcha worker runs correctly the first time you try it, then disappoints in two quiet ways. The worker option that controls how many jobs run at once defaults to 1, so a backlog of solves drains one at a time. And if the processor ever ties up the event loop, BullMQ decides the job has stalled, hands it to another worker, and the same CAPTCHA gets solved twice. Neither is a bug. Both are defaults, and both are one line to change.

What you need

  • Redis, plus BullMQ installed in the project that runs your workers.
  • CapSkip running on a Windows machine, with the Node client in the same project.
  • The sitekey and page URL arriving on the job data rather than hardcoded, so one queue serves every form.
  • A connection mode decided before you scale out, because workers usually end up on more machines than the solver does.
# npm install capskip
npm install bullmq capskip

Step 1: where the worker runs, and which connection mode that needs

Worth settling first, because BullMQ’s own advice is to run a fleet of workers across many different machines, and the moment you do that loopback stops meaning what it meant on your laptop.

There are two connection modes. Local binds to 127.0.0.1 and answers that device only, which is right when your automation and the solver share a machine. Server binds to your network address or public IP, so another box, a VPS or a hosted platform can reach the same Windows machine over the API. Server mode changes only which address the solver listens on. It is still your hardware and it is still unmetered. Both modes live under connection settings.

Where the worker process runsWhich connection mode
On the CapSkip machine, one processLocal mode. 127.0.0.1 is genuinely correct
A fleet of workers on your own networkServer mode with the solver’s LAN address
Workers in containers, or on a VPSServer mode with a reachable address and a firewall rule

A static public IP is worth arranging for a worker on a VPS, so the address does not move under a running deployment. Put the address in an environment variable rather than the source, because your laptop and your workers want different values. The client does not read any environment variable by itself, so read CAPSKIP_HOST in your code and pass it in, as the worker below does.

// npm install capskip
import { CapSkip } from "capskip";

// One client, shared by every job this worker handles.
// CAPSKIP_HOST is 127.0.0.1 locally and the solver's
// address on every other machine.
export const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: 8080,
});

Step 2: the concurrency default is one job at a time

A BullMQ worker processes one job at a time unless you tell it otherwise. That default is sensible for CPU work and badly wrong for solving, because a solve is almost entirely waiting. BullMQ says so directly: concurrency is only possible when workers perform asynchronous operations such as a call to a database or an external HTTP service. Polling a solver over HTTP is exactly that shape, so the event loop stays free the whole time.

The arithmetic is worth doing once. If a reCAPTCHA solve takes twenty seconds, one worker at the default drains three jobs a minute. The same worker at a concurrency of twenty drains sixty, on the same hardware, because nineteen of those jobs are sitting in a socket read rather than competing for CPU.

import { Worker } from "bullmq";
import { solver } from "./solver.js";

// A solve is an awaited HTTP call, so raising this costs
// almost no CPU. Size it for the solver machine and for
// what the target site will accept.
const worker = new Worker("captcha", async (job) => {
  const { sitekey, pageUrl } = job.data;
  const result = await solver.recaptcha(sitekey, pageUrl);
  return await submitForm(pageUrl, result.code);
}, { connection: { host: "127.0.0.1", port: 6379 }, concurrency: 20 });

With a metered solver that number is really a spend control, which is why so many examples set it to something timid. Here it is a capacity question about one machine, and about how fast the site you are submitting to will accept requests. The second is usually the tighter limit.

Step 3: what makes a job stall, and why stalling means solving twice

This is the failure that costs people a morning, because the logs look like the queue is working and the solver’s own log says otherwise.

When a job reaches a worker, BullMQ puts a lock on it so nothing else can touch it, and the worker has to keep telling the queue it is still working. That lock is lockDuration, 30000 ms by default, and the worker renews it at half that interval. A separate sweep, stalledInterval, runs every 30000 ms looking for locks nobody renewed. If the worker was too busy to renew in time, the job is marked stalled, moved back to waiting, and processed again by another worker. Once it has stalled more times than maxStalledCount allows, and that defaults to one, it goes to the failed set instead.

So a stalled job is not a failed job, and the re-run is not a retry. It is the queue correctly assuming the worker holding that job has died. Your solver sees two submissions for one CAPTCHA, and only the second token ever gets used.

Worker settingDefaultWhat it decides
"concurrency"1How many jobs one worker handles at once
"lockDuration"30000 msHow long the lock survives without renewal
"lockRenewTime"half of lockDurationHow often the worker renews it
"stalledInterval"30000 msHow often unrenewed locks get swept up
"maxStalledCount"1How many re-runs before the job fails

The cause is always the same: the processor held the CPU. An awaited HTTP call does not, so the client’s own polling is safe. A hand-rolled loop that busy-waits on the result endpoint is not, and neither is decoding a large image synchronously before you submit it. Let the client poll, because it starts at 250 ms and backs off rather than sleeping on a flat interval, and move any genuinely CPU-bound step out of the processor, or into a sandboxed processor.

Raising the lock duration is the wrong first move. It treats the symptom, and a long lock on a worker that really has died leaves the job untouched for that whole window.

Step 4: retries, and the token you must not keep

BullMQ does not retry by default. The attempts option is 1, which means one try and then the failed set. That is usually right for a solve, since most failures here are either a parameter mistake that will fail identically forever or a site that has moved on. Where retries earn their place is a worker that briefly could not reach the solver, so give them a backoff and keep the count small.

await queue.add("signup", { sitekey, pageUrl }, {
  // Two tries, spaced out, for a solver that was
  // briefly unreachable. Not for a bad sitekey.
  attempts: 2,
  backoff: { type: "exponential", delay: 5000 },
  // Do not leave finished jobs sitting in Redis forever.
  removeOnComplete: { age: 3600, count: 1000 },
});

Two rules go with that. Never carry a token across an attempt: a reCAPTCHA token is good for about two minutes, so solve inside the attempt that submits it, and read the token expiration guide if you are tempted to cache one. And do not return the token as the job’s return value. BullMQ keeps completed jobs by default, so that return value lands in Redis and stays there. Return the outcome of the submission instead, which is the thing you actually want to look at later.

Full working example

One producer, one worker, and the solve sitting in the same processor as the thing that consumes it.

// npm install capskip
import { Queue, Worker } from "bullmq";
import { CapSkip, ValidationException } from "capskip";

const connection = { host: "127.0.0.1", port: 6379 };
const queue = new Queue("captcha", { connection });

const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: 8080,
});

new Worker("captcha", async (job) => {
  const { sitekey, pageUrl, email } = job.data;
  try {
    // Solve and submit together. The token is short lived.
    const result = await solver.recaptcha(sitekey, pageUrl);
    const res = await postSignup(pageUrl, email, result.code);
    return { status: res.status };
  } catch (err) {
    // A bad sitekey fails the same way on every attempt.
    if (err instanceof ValidationException) {
      await job.discard();
    }
    throw err;
  }
}, { connection, concurrency: 20 });

That call is reCAPTCHA v2. The other types are the same shape: pass invisible or enterprise set to 1, or version set to v3 with an action, or call turnstile or geetest instead. The full surface is on the Node.js CAPTCHA solver page.

Common errors and what they mean

What you seeCauseFix
The queue drains far slower than the solver can goWorker concurrency is still at its default of oneRaise it. The solve is awaited I/O, not CPU
Two solves logged for one job, seconds apartThe lock was not renewed, so the job counted as stalledStop blocking the event loop in the processor
A job lands in the failed set with no thrown errorIt stalled more times than the maximum allowsThe same blocking work. Fix that, not the counter
Works on your laptop, NetworkException on the worker box127.0.0.1 on that box is that boxServer mode, and set CAPSKIP_HOST for the worker
Tokens rejected on the second attempt onlyA token from the first attempt was carried overSolve inside the attempt that submits
ERROR_WRONG_USER_KEY inside an ApiExceptionCAPSKIP_API_KEY is unset in the worker’s environmentSet the variable where the worker runs, then restart that worker
CAPCHA_NOT_READY from a hand-rolled pollThe result was read before it was finishedLet the client poll. It backs off on its own

That last response is spelled the way it looks, and the missing letter is not a typo on our side, because the API really does return it that way. It is explained in full in the CAPCHA_NOT_READY guide.

FAQ

Can my workers run on different machines from the solver?

Yes, and that is the normal setup once you scale past one process. Switch CapSkip to Server mode under connection settings so it listens on your network address instead of loopback, then set CAPSKIP_HOST in the environment of every worker. On your own network that address is a LAN address and nothing needs to face the internet. If a worker sits on a VPS, use a static public IP with a firewall rule that allows only the addresses you expect.

What concurrency should I actually set?

Start at ten and watch two things: the solver machine, and how the site you are submitting to responds. Because a solve is awaited I/O, the worker process itself is rarely the limit. The site usually is, and it will tell you by rate limiting you long before Node runs out of room. Nothing here queues behind a balance, so the number is a capacity decision rather than a budget one.

Why was the same CAPTCHA solved twice when I never set attempts?

Because that re-run was not a retry. A stalled job is re-queued regardless of the attempts option, on the assumption that the worker holding it has died. The trigger is a lock that was not renewed in time, which happens when the processor holds the CPU rather than awaiting. Find the synchronous work in the processor and move it, and the duplicate goes away.

Should the solve be its own queue that other jobs call?

Usually not. Splitting it means the token crosses a queue boundary and sits in Redis while the parent job resumes, and that is the fastest way to spend a token that has already expired. Keep the solve and whatever consumes it in the same processor, and return the outcome rather than the credential. A separate queue only makes sense when what it hands back is not a token at all.

The short version

Raise the worker concurrency, because the default of one job at a time throttles a queue of awaited HTTP calls for no reason. Keep the processor off the CPU so the lock keeps renewing, since a stalled job is re-run by another worker and you pay for that in wasted throughput. Leave attempts low and never carry a token across one. Put the address in CAPSKIP_HOST and switch the solver to Server mode the moment a worker lives somewhere else.

Worth weighing before you pick that concurrency number: CapSkip is an unlimited captcha solver that runs on hardware you already own, so raising it spends one machine’s capacity and nothing per solve.