Last month I got a Slack message at 2am from a panicked client: "Nobody can sign up. The form just spins forever." After 30 minutes of digging, the culprit was the little reCAPTCHA badge in the corner. Google's servers were having a bad day in their region, and our entire signup funnel was held hostage by a third-party script.
That wasn't the first time. It was the third in six months. So I finally did what I'd been putting off — ripped reCAPTCHA out and replaced it with something I could actually control.
This post walks through why reCAPTCHA fails in production, how to diagnose the symptoms, and the concrete steps I took to replace it without flooding the database with bot signups.
The symptoms you're probably seeing
If you're reading this, you've likely hit one of these:
- Forms that hang on submit with no error
- Console errors like
Uncaught (in promise) Timeoutfromrecaptcha/api.js - Users in certain regions (China, Iran, parts of the EU) reporting the form just doesn't work
- Privacy-conscious users complaining about the tracking
- Mysterious drops in conversion rates that correlate with reCAPTCHA loading times
I tracked our signup form for two weeks and found that reCAPTCHA was adding ~1.2 seconds to the median page load, with a long tail of failures past 8 seconds. That's a lot of users bouncing before they even see the form.
The root cause: why third-party CAPTCHA is fragile
The core problem isn't that reCAPTCHA is bad code. It's that you've handed control of a critical user flow to a script you don't host. Three things tend to break:
1. Network dependency on a single CDN
The script loads from google.com/recaptcha/api.js. If that hostname is blocked, slow, or geo-restricted, your form is dead. There's no graceful fallback unless you write one yourself.
2. Silent script failures
When the script fails to load, the global grecaptcha object is undefined. If your submit handler calls grecaptcha.execute() without a guard, you get a silent JS error and the form does nothing visible to the user.
// This is the bug I see in 90% of integrations
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Crashes if script didn't load
const token = await grecaptcha.execute(SITE_KEY, { action: 'submit' });
await submitForm(token);
});
3. The score-based v3 trap
reCAPTCHA v3 returns a score from 0.0 to 1.0. You're supposed to decide a threshold. But the score is opaque — legitimate users on VPNs or with privacy extensions routinely get 0.1 scores. You end up either blocking real users or letting bots through.
Step-by-step: replacing it with a proof-of-work check
My approach: combine a lightweight client-side proof-of-work challenge with server-side heuristics. No external script, no tracking, no single point of failure.
Step 1: Server issues a challenge
When the form loads, the server generates a challenge with a difficulty parameter. The client must find a nonce that, when hashed with the challenge, produces a hash with N leading zeros.
// server.js — issue a challenge tied to the session
import crypto from 'node:crypto';
app.get('/api/challenge', (req, res) => {
// 32 random bytes, valid for 10 minutes
const challenge = crypto.randomBytes(16).toString('hex');
const expires = Date.now() + 10 * 60 * 1000;
// Sign it so the client can't forge a fresh challenge later
const signature = crypto
.createHmac('sha256', process.env.CHALLENGE_SECRET)
.update(`${challenge}:${expires}`)
.digest('hex');
res.json({ challenge, expires, signature, difficulty: 4 });
});
Step 2: Client solves the challenge
The browser hashes increments of a nonce until it finds one matching the difficulty. With difficulty 4 (4 leading zeros), this takes 50-200ms on a modern phone — invisible to humans, but adds up for bots hitting the form thousands of times.
// client.js — solve before submitting
async function solveChallenge(challenge, difficulty) {
const target = '0'.repeat(difficulty);
let nonce = 0;
while (true) {
const input = new TextEncoder().encode(`${challenge}:${nonce}`);
const hashBuffer = await crypto.subtle.digest('SHA-256', input);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
if (hash.startsWith(target)) return nonce;
nonce++;
}
}
Note: I'm using crypto.subtle from the Web Crypto API, which is supported in every modern browser. Check the MDN docs if you need to support older targets.
Step 3: Server verifies the solution
On form submit, the server checks the HMAC signature, expiry, and re-hashes to confirm the nonce is valid.
app.post('/api/signup', async (req, res) => {
const { challenge, expires, signature, nonce, ...formData } = req.body;
// Verify the challenge hasn't expired
if (Date.now() > expires) {
return res.status(400).json({ error: 'Challenge expired' });
}
// Verify we actually issued this challenge
const expected = crypto
.createHmac('sha256', process.env.CHALLENGE_SECRET)
.update(`${challenge}:${expires}`)
.digest('hex');
if (signature !== expected) {
return res.status(400).json({ error: 'Invalid challenge' });
}
// Verify the proof of work
const hash = crypto
.createHash('sha256')
.update(`${challenge}:${nonce}`)
.digest('hex');
if (!hash.startsWith('0000')) {
return res.status(400).json({ error: 'Invalid proof' });
}
// Also: rate limit by IP, check honeypot field, etc.
await createUser(formData);
res.json({ ok: true });
});
Step 4: Layer in cheap heuristics
Proof-of-work alone won't stop a determined attacker — they'll just rent more compute. Stack it with:
- Honeypot fields: a hidden input that humans won't fill but dumb bots will
- Rate limiting per IP: I use a sliding window in Redis, 5 signups per IP per hour
- Submission timing: if the form is submitted in under 2 seconds from page load, it's almost certainly a bot
- Email domain checks: block disposable email providers if your business allows
Prevention: don't get burned again
A few habits I've picked up after migrating four projects off third-party CAPTCHAs:
- Never put a critical flow behind a script you don't host. If you must, always have a fallback path.
- Monitor third-party script load times as a separate metric. Real User Monitoring with the PerformanceObserver API makes this trivial.
- Test your form with the third-party script blocked. Pop open DevTools, block the domain, and try to sign up. If it doesn't fail gracefully, fix that before shipping.
- Measure bot signups before and after. I keep a daily count of signups that never confirm their email — that's my bot-rate proxy.
After the migration, our signup completion rate jumped 14% and we stopped getting 2am Slack messages. Bot signups went up slightly but stayed well within what a basic moderation queue can handle.
The lesson: control your critical paths. CAPTCHAs are a tool, not a religion — and the best one is often the one you write yourself.
Top comments (0)