The Problem
A client swapped reCAPTCHA v3 for Cloudflare Turnstile on their contact and signup forms last month. The forms work, the spam rate dropped, and the privacy review team was happy. Then the Core Web Vitals report in Search Console flipped from green to red for mobile INP across the entire site, not just the pages with the form.
Field data from CrUX showed the p75 INP for mobile jumping from 142ms to 318ms within ten days of the rollout. Desktop INP barely moved. The PageSpeed Insights lab report showed a clean pass on a single page test, but the live field data was unambiguous.
The most painful part: the regression hit pages that did not even render the Turnstile widget. The homepage, the blog, the pricing page, all worse INP, all without a form on the page. The script tag was in the global layout because that is how the integration docs suggest you set it up.
Why It Happens
The Cloudflare Turnstile embed script, challenges.cloudflare.com/turnstile/v0/api.js, weighs about 70KB compressed and registers a handful of message listeners on the window the moment it loads. The widget itself is lazy-instantiated by an IntersectionObserver, and that part is fine. The script's own initialization is not.
On mobile, the first interaction after page load is usually a tap somewhere on the body. That tap triggers a touchend handler that the Turnstile script registered globally to detect when a user is interacting with the page, used for the "interactive challenge" path. The handler runs synchronously, walks the DOM looking for widget mount points, and posts a message to its iframe pool. On a low-end Android device that pass takes 180-220ms of main thread time. INP measures the gap from input to next paint, and that synchronous walk lands right inside the gap.
Because the script is in the global layout, every page on the site pays this cost on first interaction, even pages with no widget to mount. The widget detection runs anyway, finds nothing, and returns. The cost is paid before the early-out.
The Cloudflare Turnstile docs recommend loading the script with defer and putting it in the head. That helps with LCP. It does nothing for INP because the handler registers as soon as the script executes, regardless of whether it was deferred. The fix is to keep the script out of pages that do not need it and lazy-mount it on pages that do.
The Fix
Two changes. Stop loading the script globally, and only mount the widget when the user is about to interact with the form.
Step 1: Move the script tag out of the global layout. Whatever framework you are on, the pattern is the same: put the loader in the form component, not in the document head. For Next.js App Router:
// app/components/turnstile.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import Script from 'next/script'
export function Turnstile({ siteKey, onVerify }: Props) {
const ref = useRef<HTMLDivElement>(null)
const [scriptReady, setScriptReady] = useState(false)
useEffect(() => {
if (!scriptReady || !ref.current) return
const widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onVerify,
})
return () => window.turnstile.remove(widgetId)
}, [scriptReady, siteKey, onVerify])
return (
<>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
strategy="lazyOnload"
onLoad={() => setScriptReady(true)}
/>
<div ref={ref} />
</>
)
}
render=explicit stops the script from auto-scanning the DOM on load, which kills the global touchend handler that was causing the INP hit. The widget only mounts when you call window.turnstile.render() from your own code.
Step 2: Defer mounting the widget until the user touches the form. The user does not need the captcha rendered before they start filling in fields. Render it on first focus or first scroll of the form into view, whichever comes first:
// app/components/contact-form.tsx
'use client'
import { useState, useRef, useEffect } from 'react'
import { Turnstile } from './turnstile'
export function ContactForm() {
const [needsWidget, setNeedsWidget] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
if (!formRef.current) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setNeedsWidget(true)
observer.disconnect()
}
},
{ rootMargin: '200px' },
)
observer.observe(formRef.current)
return () => observer.disconnect()
}, [])
return (
<form ref={formRef}>
{/* form fields */}
{needsWidget && <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onVerify={() => {}} />}
</form>
)
}
The IntersectionObserver mounts the widget 200px before the form scrolls into view, which is enough lead time for the network fetch and the iframe init to complete before the user reaches it. On pages without the form, none of this code runs, the script never loads, and the global touchend handler never registers.
Step 3: Verify the field data is recovering. Field data from CrUX updates on a 28-day rolling window so changes do not show up immediately. Use the PageSpeed Insights API with the loadingExperience field to track the 28-day p75 INP daily, or open the Search Console Core Web Vitals report and watch the trend line for the affected URL group. Lab data with mobile throttling should drop within minutes of the deploy. If lab INP is still above 200ms for the form page, instrument with Long Animation Frames API to find which handler is still running long. See my notes on long animation frames debugging for the pattern.
If you also have a third-party chat widget, an analytics tag, and a consent banner all loading in the head, expect the INP recovery to plateau higher than your pre-Turnstile baseline. Each one of them is registering its own listeners on first interaction. Lazy-mount them the same way.
The Lesson
Turnstile is a privacy upgrade over reCAPTCHA and lighter on the wire. It is not lighter on the main thread when the script loads on every page, because its first-interaction handler runs a synchronous DOM walk regardless of whether a widget exists. Move the script into the form component, use render=explicit to disable the global scanner, and defer mounting until the form is about to be visible. Do the same for every third-party script that hooks into pointer events.
If your INP is suffering after a vendor swap and you need someone to track the regression to its source, that is the kind of work I do. See my services. For a related INP issue caused by a different third-party tag, read INP regression from GTM third-party tags.
INP collapsed after a vendor change? Let me track it down.