Turnstile error 300030: the widget hung
Error 300030 means the Turnstile widget mounted and then stopped making progress: api.js heartbeats every running widget, and a widget that misses roughly 40 seconds of those beats is declared hung, marked failed, and reported to your error-callback as the string "300030". It is a stalled-widget error, not a bad-sitekey error — a container that is not visible, a form injected after page load, or a partially blocked challenges.cloudflare.com cover most real cases.
What 300030 actually means
Cloudflare documents the whole 300* range as one row — "Generic challenge failure", retryable, "Bot behavior detected". That row is not much help when you are debugging your own widget, because the individual codes in that family are not all the same failure.
The behaviour is visible in the shipped api.js. The script keeps a map of live widgets and runs a watchdog on an interval. Every tick it posts a heartbeat message into each widget's iframe and expects an acknowledgement. If a widget that is still executing — not complete, not already failed — has not acknowledged for about 45 ticks (roughly 40 seconds at the current 900 ms interval), api.js logs Turnstile Widget seem to have hung: with the widget ID, force-fails the widget, and emits code 300030. The sibling code 300031 covers the same watchdog deciding the widget crashed rather than stalled.
So 300030 tells you the iframe was created and then went quiet. The challenge itself never got far enough to pass or fail.
// api.js hands the code to your callback as a STRING, not a number. turnstile.render('#widget', { sitekey: '0x4AAAAAAAAAAAAAAAAAAAAA', 'error-callback': (code) => { console.error('turnstile failed:', code); // "300030" return true; // tells Turnstile you handled it; suppresses its own console warning } }); // With no error-callback, api.js throws instead of returning: // Uncaught [Cloudflare Turnstile] Error: 300030. // and separately logs: // Turnstile Widget seem to have hung: <widgetId>
The 900 ms interval and 45-tick threshold come from reading the current api.js bundle. They are internal and can change. The observable contract — widget mounted, then stopped responding — is what to debug against.
Codes 300030 gets confused with
Before chasing a hang, confirm you are not looking at a configuration error wearing the wrong number. Turnstile has dedicated codes for all of these, and none of them surface as 300030.
| Code | Cloudflare's description | What it really tells you |
|---|---|---|
| 110100 / 400020 | Invalid sitekey | The sitekey string is malformed or does not exist. Not a rendering problem. |
| 110110 | Sitekey not found | Typo, or the widget was deleted in the dashboard. |
| 110200 | Domain not authorized | The hostname you are serving from is missing from the widget's Hostname Management list. |
| 400070 | Sitekey disabled | The widget exists but is turned off. |
| 200500 | Iframe load error | The iframe could not load at all — challenges.cloudflare.com is fully blocked or unreachable. |
| 110600 / 110620 | Challenge / interaction timed out | The visitor's clock is off, or nobody touched an interactive checkbox in time. |
| 300030 | (documented only as 300*) | The iframe loaded and then stopped responding to the parent script. |
If you are seeing 300030 on some visitors and 110200 on others, treat them as two separate bugs.
Causes, in the order worth checking
| Cause | What you see alongside it | Fix |
|---|---|---|
| Widget rendered into a container that is not visible — a closed modal, a hidden tab panel, a collapsed accordion | Nothing paints; 300030 arrives roughly 40 seconds after render | Render on open, remove on close. api.js classifies containers with display: none, visibility: hidden, visibility: collapse or opacity <= 0.01 as unexpectedly hidden — unless the widget is in invisible mode or appearance: interaction-only, where hidden is expected. |
Form injected after DOMContentLoaded while using implicit rendering | No widget renders at all, or one renders into a node that is still hidden | Switch to ?render=explicit and render when the form appears. The implicit scan for .cf-turnstile runs once, on the ready event. |
| Container removed or replaced by the framework without cleanup (React remount, htmx swap, Turbo navigation) | Console: Cannot find Widget <id>, consider using turnstile.remove() to clean up a widget. | Call turnstile.remove(widgetId) in your teardown path, then render fresh. |
api.js included twice — once by your theme or layout, once by a form plugin or component | Console: Turnstile already has been loaded. Was Turnstile imported multiple times? | Include exactly one script tag. The second load is ignored, so whichever tag wins decides implicit vs explicit mode. |
api.js self-hosted, proxied, bundled, minified or concatenated by an optimiser | Could not find Turnstile valid script tag, some features may not be available, or Could not parse Turnstile script tag URL | Load it verbatim from https://challenges.cloudflare.com/turnstile/v0/api.js. Cloudflare states that proxying or caching this file breaks Turnstile on future updates. |
turnstile.ready() called with an async/defer script tag | Throws Remove async/defer from the Turnstile api.js script tag before using turnstile.ready(). | Drop async/defer, or use ?render=explicit&onload=yourCallback instead. |
challenges.cloudflare.com partially reachable — content blocker, DNS filter, corporate proxy, or a CSP that allows the frame but not its connections | iframe mounts, spinner never resolves | Allow the origin in script-src, connect-src and frame-src. A total block gives 200500 instead. |
Render explicitly for anything that appears late
This is the fix for the majority of self-inflicted 300030s. Implicit rendering scans the document once for .cf-turnstile elements and never looks again, so a widget inside a modal, a wizard step, or a lazily fetched form is either missed entirely or mounted into a node that is not on screen. Explicit rendering plus a MutationObserver closes both gaps: mount when the container becomes visible, remove when it stops being visible so the watchdog has nothing left to time out.
// <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad" defer></script> // Containers use a data attribute, NOT the cf-turnstile class, so the implicit scan ignores them: // <div data-turnstile data-sitekey="0x4AAAAAAAAAAAAAAAAAAAAA"></div> const widgets = new WeakMap(); function isVisible(el) { if (!el.isConnected) return false; if (typeof el.checkVisibility === 'function') { return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); } return el.offsetWidth > 0 || el.offsetHeight > 0; } function reconcile() { for (const el of document.querySelectorAll('[data-turnstile]')) { const id = widgets.get(el); const visible = isVisible(el); if (visible && id === undefined) { widgets.set(el, turnstile.render(el, { sitekey: el.dataset.sitekey, callback: (token) => { el.closest('form')?.dispatchEvent(new CustomEvent('turnstile:token', { detail: token })); }, 'error-callback': (code) => { console.error('[turnstile]', code); return true; } })); } else if (!visible && id !== undefined) { turnstile.remove(id); // stop the watchdog before it can report 300030 widgets.delete(el); } } } window.onTurnstileLoad = function () { reconcile(); new MutationObserver(reconcile).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'hidden', 'open'] }); };
turnstile.render() accepts a CSS selector or an HTMLElement, so you can pass the node straight through. Reconciling on every mutation is cheap because the work is one querySelectorAll plus a WeakMap lookup; if your page mutates constantly, debounce reconcile with requestAnimationFrame.
Open → close → open is the case that catches people out. Without the turnstile.remove() branch, the second open reuses a widget whose iframe was hidden mid-challenge, and the watchdog fires on the stale one.
Confirm api.js loads once, unmodified
Duplicate loading is common on WordPress, on sites where a theme and a form plugin both inject the script, and in component libraries that ship their own tag. The second copy does not replace window.turnstile; it warns and stops. The damage is indirect: the surviving instance may be in the mode you did not intend, and an onload= callback on the losing tag can still fire and call turnstile.render() a second time against the same container.
// Paste in DevTools on the broken page. Expect exactly one entry. [...document.querySelectorAll('script[src*="challenges.cloudflare.com"]')] .map((s) => s.src); // Also check the console for either of these strings: // "Turnstile already has been loaded. Was Turnstile imported multiple times?" // "Could not find Turnstile valid script tag, some features may not be available" // The second one means the tag was rewritten — minified, inlined, or served from your own domain.
If you run an asset optimiser, exclude the Turnstile script from minification, concatenation, delayed loading and "defer all JavaScript" rules. The file must be fetched from Cloudflare's URL as-is.
Handle the error rather than letting it throw
With no error-callback, api.js throws an exception when a widget fails. On a login page that exception can take out whatever ran after it. Define the callback even if all it does is log.
Some 300030s are not yours to fix: a content blocker on the visitor's machine, an outdated browser, a filtering corporate proxy, or a VPN that mangles the connection. Cloudflare's own troubleshooting list for this family is browser compatibility, extensions, JavaScript enabled, private mode, another device, no VPN or proxy, different network. You cannot apply any of that remotely, so give the visitor a route forward instead.
let attempts = 0; turnstile.render('#widget', { sitekey: '0x4AAAAAAAAAAAAAAAAAAAAA', retry: 'never', // take over recovery instead of letting api.js loop 'error-callback': (code) => { attempts += 1; if (attempts <= 2) { setTimeout(() => turnstile.reset('#widget'), 3000); return true; } document.querySelector('#verify-help').textContent = `Verification could not complete (error ${code}). Disable content blockers for this page, ` + `or try a different network.`; return true; } });
Log the code server-side alongside user agent and whether the container was visible at render time. A 300030 rate that is flat across browsers points at your markup; one concentrated in a single browser or corporate network points at blocking.
Automating a Turnstile-protected page you own
Debugging 300030 and automating past a widget are different problems, and most people reading this page have the first one. If you also need a headless browser to get through your own Turnstile — end-to-end tests, uptime checks, or anti-bot testing against a property you control — a stalled widget in CI looks identical to a stalled widget in production, and you end up debugging the harness rather than the site.
SolveGate exists for that case: one POST /v1/solve with gate, sitekey and url returns a token, typically in under 1.5 seconds, for managed, non-interactive, invisible and Turnstile WAF challenge pages. Failed solves are never billed, and the first 1,000 solves are free. Use it only against properties you own or are authorised to test.
Common questions
It means a Turnstile widget was created and then stopped responding to the heartbeat that api.js sends into the widget iframe. After roughly 40 seconds without an acknowledgement, the script marks the widget failed and reports code 300030, logging "Turnstile Widget seem to have hung". Cloudflare documents the code only as part of the generic 300* family.
No. An invalid sitekey returns 110100 or 400020, a missing one returns 110110, a disabled one returns 400070, and a hostname that is not in the widget's allowed list returns 110200. If you are getting 300030, the sitekey and domain resolved and the challenge started.
Because the widget gets rendered into a container that is not visible. api.js treats containers with display: none, visibility: hidden or near-zero opacity as unexpectedly hidden, the challenge inside cannot make progress, and the watchdog reports 300030. Render the widget when the modal opens and call turnstile.remove(widgetId) when it closes.
Loading it twice is a real fault worth removing, though the second load is ignored rather than fatal — it logs "Turnstile already has been loaded. Was Turnstile imported multiple times?". The practical risk is that the surviving instance runs in implicit or explicit mode contrary to your intent, or that an onload callback renders a second widget into the same container. Confirm exactly one script tag points at challenges.cloudflare.com.
Sometimes. Cloudflare's troubleshooting steps for this error family are to disable browser extensions such as ad blockers, use an up-to-date browser with JavaScript enabled, try private mode or another device, and drop any VPN or proxy. If the error is concentrated on one network or one extension, that is the cause; if it appears across all browsers, the bug is in your page.
Both come from the same watchdog in api.js. 300030 is emitted when a running widget stops acknowledging heartbeats — treated as hung. 300031 is emitted when the widget is judged to have overrun and crashed instead. Neither is documented individually; both fall under the 300* row in Cloudflare's error code table.
Related
More in Turnstile errors
Automating a gate you own or are authorised to test?
// SolveGate clears Cloudflare Turnstile and WAF challenges through one REST call · first 1,000 solves free