Skip to content
Jul 8, 2026

Automation

How to solve CAPTCHAs in Puppeteer

CT

CaptchaSonic Team

Automation & Web Scraping

Puppeteer drives headless Chrome beautifully — right up until a reCAPTCHA or Cloudflare Turnstile challenge appears and your run stalls. This guide walks through a production-grade integration: detect the challenge, solve it with CaptchaSonic over gRPC, inject the token, and continue — plus the mistakes that quietly break scrapers at scale.

TL;DR

  • Install captchasonic alongside puppeteer and set CAPTCHASONIC_API_KEY.
  • Read the sitekey from the page, call client.solveRecaptchaV2Token(...), and inject the returned gRecaptchaResponse into #g-recaptcha-response.
  • Solve late (tokens expire in ~120 s), read sitekeys at runtime, and route solves through a proxy when the target ties the token to the solving IP.
  • The same client handles Turnstile, GeeTest, AWS WAF, and image captchas — swap the method.

Why CAPTCHAs are hard in Puppeteer

Puppeteer can click and type, but it can't reason about an image grid or produce a valid reCAPTCHA token. Those tokens are minted by Google/Cloudflare after a challenge their anti-bot model accepts — you can't forge one locally. So the reliable pattern isn't "click the tiles faster," it's: hand the challenge to a solving service, get a real token back, and place that token where the page expects it. CaptchaSonic does the solve server-side over a native gRPC connection and streams the token back the moment it's ready, so there's no HTTP poll gap.

What you need before starting

  • Node.js 18+ and Puppeteer installed.
  • A CaptchaSonic API key with credits (get one, then add funds).
  • The target's sitekey (the data-sitekey attribute on the captcha element) and page URL.
npm install captchasonic puppeteer
export CAPTCHASONIC_API_KEY=sonic_your_key_here   # dashboard → API Keys

Step 1 — Launch Puppeteer and reach the challenge

What to do

Launch Chrome, navigate to the page, and wait until the captcha element is actually in the DOM before you read anything off it.

import puppeteer from 'puppeteer';
import { CaptchaSonic } from 'captchasonic';

const client = new CaptchaSonic(process.env.CAPTCHASONIC_API_KEY);

const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.goto('https://example.com/login', { waitUntil: 'networkidle2' });

// Wait for the captcha to render, then read the sitekey from the live DOM.
await page.waitForSelector('[data-sitekey]', { timeout: 15_000 });
const sitekey = await page.$eval('[data-sitekey]', (el) => el.getAttribute('data-sitekey'));

Why this matters

reCAPTCHA and Turnstile load asynchronously. If you read data-sitekey before the widget mounts, you get null and the solve fails with a confusing "invalid sitekey" error. Waiting on the selector makes the step deterministic across slow networks and cold page loads.

Common mistakes to avoid

  • Hard-coding the sitekey. It changes between staging and production — read it at runtime.
  • Forgetting --no-sandbox. Required in most Docker/CI images or Chrome won't start.
  • Using networkidle0 on chatty pages. Analytics/websocket traffic can keep the page from ever reaching "0 connections" — networkidle2 is the safer wait condition.

Step 2 — Solve the CAPTCHA with CaptchaSonic

What to do

Send the sitekey and page URL to CaptchaSonic. One typed call blocks until a token is ready — the SDK handles polling, retries, and error mapping internally.

const result = await client.solveRecaptchaV2Token({
  websiteURL: page.url(),
  websiteKey: sitekey,
});
const token = result.solution.gRecaptchaResponse;

Why this matters

The transport is gRPC by default, so the token is pushed to you the instant the solve completes rather than on your next poll interval — that shaves seconds off every solve and keeps long scraping runs fast. Because the SDK owns retry logic, transient network blips don't surface as failures in your automation code.

Common mistakes to avoid

  • Solving too early. reCAPTCHA tokens live ~120 seconds. Call the solve right before you submit, not at the top of the script — otherwise it expires while you fill the form.
  • Ignoring IP coherence. If the target validates that the token was solved from the same IP that submits it, pass a proxy so CaptchaSonic solves through your egress: client.solveRecaptchaV2Token({ websiteURL, websiteKey: sitekey, proxy: 'http://user:pass@host:port' }).
  • Swallowing errors. Catch InsufficientBalanceError / rate-limit errors explicitly so a drained balance doesn't look like a broken selector.

Step 3 — Inject the token and continue

What to do

Write the token into the hidden #g-recaptcha-response field (or invoke the site's callback), then submit as normal.

await page.evaluate((t) => {
  const el = document.getElementById('g-recaptcha-response');
  if (el) el.innerHTML = t;
}, token);

await page.click('button[type="submit"]');
await page.waitForNavigation({ waitUntil: 'networkidle2' });
await browser.close();

Why this matters

The page's own submit handler reads g-recaptcha-response and treats it exactly as if a human had solved the challenge — no special API on the target's side is required.

Common mistakes to avoid

  • Callback-bound tokens. Many sites don't read the textarea directly; they fire a JS callback declared as data-callback="onCaptcha". In that case call it with the token inside page.evaluate((t) => window.onCaptcha(t), token) instead of writing the field.
  • Not waiting for navigation. Submitting and closing the browser immediately can abort the request in flight — wait for navigation (or the post-login selector) first.

Handling other CAPTCHA types

The same client solves the rest — you only change the method:

  • Cloudflare Turnstileclient.solveTurnstileToken({ websiteURL, websiteKey })
  • GeeTest, AWS WAF, and image/OCR captchas each have a dedicated method

See the full set and their return fields in the Node.js SDK reference and the Capability Matrix.

Scaling in production

  • Concurrency. Reuse one CaptchaSonic client across many pages — the gRPC channel multiplexes solves, so you don't pay TLS setup per request.
  • Proxies. Route both the browser and the solve through the same proxy pool so token and submission IPs stay coherent.
  • Retries and budgets. Treat a failed solve as retryable, but cap attempts and watch your balance — a solve that keeps failing usually means a stale sitekey or a blocked IP, not a transient error.

FAQ

Does this work with headful Chrome too? Yes — drop headless: true. The solve flow is identical; only the launch options change.

Can I use puppeteer-extra stealth plugins alongside it? Yes. Stealth reduces how often you're challenged; CaptchaSonic handles the challenges you still get.

What about invisible reCAPTCHA / v3? Both are supported. v3 returns a scored token you inject the same way — see the SDK reference for the exact method.

Next steps