The CAPTCHA That Isn't There: Why render() Silently Does Nothing

A signup page we shipped had a gap where the bot challenge should have been. Not a broken image, not an error box — a blank strip of page. The form submitted, the server rejected it for failing verification, and the person trying to buy saw a generic failure with nothing to act on.
Everything passed locally. Everything passed in the test suite. The widget was missing only in production, and only for real visitors.
What was actually happening
Cloudflare Turnstile is loaded as a script, then drawn by calling window.turnstile.render(element, options). The obvious integration looks like this:
const s = document.createElement("script");
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
s.onload = () => window.turnstile.render(box, { sitekey: KEY, callback: onToken });
document.head.appendChild(s);
That is wrong in a way that is very hard to see. The script's load event fires when the file has
been fetched and evaluated — not when the API inside it has finished initialising. api.js
defines window.turnstile early, with a ready stub, and completes setup afterwards. Call
render() in between and it does not throw, does not warn, and does not draw. It returns, and
nothing happens.
A silent no-op is the worst possible failure mode here, because every signal you would normally check looks healthy:
- the script tag is in the DOM with the right
src window.turnstileis defined, so a defensiveif (window.turnstile)guard passes- no exception reaches the console or the error tracker
- the form still submits, so no client-side error is recorded
- the server rejects for a missing token, which reads as a bot, not as a bug
Our own first attempt at a fix made it worse in an instructive way. We added
window.turnstile.ready(draw), which is the documented way to queue work until the API is usable.
The browser console then printed Cloudflare's own warning:
[Cloudflare Turnstile] turnstile.ready() would break if called before the Turnstile api.js script is loaded by visitors.
ready existed on the stub, we called it too early, it warned and did nothing, and render() still
never ran. We had replaced one silent no-op with a noisy one.
The fix: let the script tell you when it is ready
Turnstile supports a documented handshake for exactly this. You declare a global callback, name it
in the script URL with onload=, and render only from inside it. The API calls your function when
it is genuinely able to draw.
const CB = "__turnstileLoaded";
window[CB] = () => {
// Safe here, and only here.
widgetId = window.turnstile.render(box, { sitekey: KEY, callback: onToken });
};
const s = document.createElement("script");
s.src = `https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=${CB}`;
s.async = true;
s.defer = true;
document.head.appendChild(s);
Two details matter. render=explicit stops the API auto-rendering anything carrying the
cf-turnstile class, which you want when you are placing the widget yourself. And the global must be
assigned before the script is appended, or the API will look for a function that does not exist
yet.
Make it survive a second copy of the script
A page assembled from several sources can end up loading api.js twice — a legacy bundle, a tag
manager, a component that does its own loading. Two copies racing is its own bug, and your onload
callback is not guaranteed to fire for a copy somebody else loaded.
So resolve on whichever signal arrives first: your callback, or a poll that notices the API became
usable. The readiness test is the important part. The presence of window.turnstile proves nothing,
because the stub exists before setup finishes. A callable render is the real signal:
const canRender = () => typeof window.turnstile?.render === "function";
And never add a second copy:
if (!document.querySelector('script[src*="turnstile/v0/api.js"]')) {
// ...append ours
}
Fail loudly instead of leaving a gap
If neither signal arrives inside a timeout, reject and tell the visitor the challenge could not load, with a route to a human. A blank gap gives them nothing. A sentence explaining that verification could not load, and offering another way to reach you, keeps the sale alive.
const started = Date.now();
const poll = () => {
if (settled) return;
if (canRender()) return succeed();
if (Date.now() - started > 15_000) return fail("turnstile api did not initialize");
setTimeout(poll, 100);
};
Fifteen seconds is long enough for a slow connection and short enough that it is not indistinguishable from a hang.
Why the tests did not catch it
They stubbed the widget. Every test replaced window.turnstile with a fake that returned a token, so
the suite proved the form worked given a token and said nothing about whether a token could ever be
obtained. Stubbing it is reasonable — but it meant the one integration that could only fail in a real
browser was the one thing never exercised in a real browser.
The loader now lives in its own module with the browser injected, and is unit tested against a fake document and a virtual clock. The test that matters most asserts the distinction that caused the outage:
test("canRender is false for the pre-init stub that only has ready()", () => {
assert.equal(canRender({ turnstile: { ready: () => {} } }), false);
});
Weaken that check to typeof win.turnstile === "object" — which is what the original buggy code
effectively believed — and the suite fails. That is the regression that would otherwise come back.
A debugging note that cost us an afternoon
While diagnosing this we drove the live page with an instrumented browser and concluded that
Cloudflare was injecting its own conflicting window.turnstile. That was wrong, and the way it was
wrong is worth knowing.
Playwright's page.addInitScript() installs a script that runs before every document in that
browser context, and it persists across navigations for the life of the context. An earlier test
had installed a Turnstile stub. Every later page in that context — including pages we believed were
clean — got the stub. render() returned "stub" and we read it as evidence of a third-party
conflict.
In a fresh browser.newContext(), window.turnstile was simply undefined until api.js loaded.
If you are using an instrumented browser to diagnose a production problem, create a new context for the run. Anything you injected earlier is still there, and it will happily answer your questions with your own test data.
The checklist
If a third-party widget renders in development and not in production:
- Check whether the library distinguishes script loaded from API ready. Many do. The script
loadevent is almost never the right signal. - Look for a documented
onloador ready callback and use it, rather than guessing with timers. - Test readiness by probing for the function you need, not for the namespace object.
- Make sure only one copy of the script can be added.
- Give the failure a visible outcome and a timeout, so a dead integration cannot present as empty space on the page.
- Verify in a clean browser context, with no prior instrumentation, against the real origin.


