The invisible failure
The services page embeds a Calendly scheduling widget in an iframe. It
loads lazily via IntersectionObserver and shows a spinner until the iframe
fires onLoad; it's a clean pattern and it should work well, right up
until the iframe doesn't load at all. Ad blockers, corporate proxies, CSP
policies, and flaky connections can each keep the iframe from loading, and
when that happens the spinner just spins forever: no error, no feedback, no
way for the visitor to book a call. The page looks broken because it is.
The timeout
The fix is about as simple as it sounds: if the iframe hasn't loaded after 8 seconds, give up and show a fallback.
const LOAD_TIMEOUT_MS = 8000;
const [shouldLoad, setShouldLoad] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
if (!shouldLoad || isLoaded) return;
const timer = setTimeout(() => setTimedOut(true), LOAD_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [shouldLoad, isLoaded]);There are three states: shouldLoad (IntersectionObserver triggered),
isLoaded (iframe fired onLoad), and timedOut (8 seconds elapsed without
onLoad). The timeout only starts once shouldLoad is true, and it clears
itself if isLoaded arrives first.
The fallback
When the timeout fires, the whole embed gets replaced with a card holding a direct link to Calendly:
if (timedOut && !isLoaded) {
return (
<div
className='flex flex-col items-center justify-center gap-4
rounded-xl border border-border bg-surface p-12 text-center'
>
<Calendar className='h-10 w-10 text-text-tertiary' />
<p className='text-text-secondary text-sm max-w-md'>
The scheduling widget couldn't load. Book directly on Calendly
instead.
</p>
<Button
name='calendly-fallback'
as='link'
href={CALENDLY_URL}
target='_blank'
rel='noopener noreferrer'
onClick={() =>
analytics.ctaClick('services_calendly_fallback', CALENDLY_URL)
}
>
Open Calendly
</Button>
</div>
);
}The fallback click is tracked with analytics so I can see how often people actually hit this path. If that number ever gets high, there's a conversation to have about whether an iframe embed is the right call at all.
Why 8 seconds
The timeout has to be long enough that slow connections can still load the embed, and short enough that people don't give up and leave. Calendly's embed is heavy: it loads its own JavaScript, CSS, and API calls, and on a 3G connection 4-5 seconds is realistic. I picked 8 seconds as a buffer above that, so a visitor on a working-but-slow connection never sees the fallback, while a visitor whose ad blocker killed the request sees it after a wait that's still reasonable.
The late load
If the iframe loads after the timeout has already fired, the onLoad handler
still does its job:
const onIframeLoad = useCallback(() => {
setIsLoaded(true);
setTimedOut(false);
}, []);Setting timedOut back to false flips the UI from fallback to embed. This
covers the rare case where a slow connection delivers the iframe after 8
seconds: the visitor sees the fallback briefly, then the real widget shows up.
It's not ideal, but it beats showing the fallback forever when the embed
eventually loads anyway.
The takeaway
Third-party iframes fail silently; they don't throw errors, they don't fire
onerror, and they don't tell you they've been blocked. A timeout with a
direct-link fallback turns a broken-looking page back into a working one, and
any embedded widget where you control neither the content nor the network path
deserves exactly that treatment.
