Managed IT · October 6, 2026 · intSignal Team

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

Share this article

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.turnstile is defined, so a defensive if (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:

  1. Check whether the library distinguishes script loaded from API ready. Many do. The script load event is almost never the right signal.
  2. Look for a documented onload or ready callback and use it, rather than guessing with timers.
  3. Test readiness by probing for the function you need, not for the namespace object.
  4. Make sure only one copy of the script can be added.
  5. Give the failure a visible outcome and a timeout, so a dead integration cannot present as empty space on the page.
  6. Verify in a clean browser context, with no prior instrumentation, against the real origin.
Share this article