The Problem
The services page embeds a Calendly scheduling widget in an iframe. When it works, users book a call without leaving the site. When it doesn't, whether that's an ad blocker, a corporate firewall, or a flaky CDN, they see a spinner that never stops.
There's no error event to hang onto either. Iframes don't fire onerror when a third-party
domain is blocked, and the onload event simply never fires, so the only signal
you get is silence.
The Pattern
The fix is a timeout: if the iframe hasn't loaded after a reasonable window, show a fallback that lets users finish the action through a different path.
const LOAD_TIMEOUT_MS = 8000;
const [isLoaded, setIsLoaded] = useState(false);
const [timedOut, setTimedOut] = useState(false);
// Start the timeout when the iframe begins loading
useEffect(() => {
if (!shouldLoad || isLoaded) return;
const timer = setTimeout(() => setTimedOut(true), LOAD_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [shouldLoad, isLoaded]);The timeout only runs while shouldLoad is true (the container is in
the viewport) and isLoaded is false. If the iframe loads before the
timer fires, the cleanup function clears it; if it loads after
the timer fires, which means a slow network rather than a block, onLoad resets timedOut
to false and shows the embed anyway.
The Fallback
When the timeout triggers, I swap the spinner out for a direct link:
if (timedOut && !isLoaded) {
return (
<div className='flex flex-col items-center justify-center gap-4 ...'>
<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'
onClick={() =>
analytics.ctaClick('services_calendly_fallback', CALENDLY_URL)
}
>
Open Calendly
</Button>
</div>
);
}The fallback does three things right:
- Says what happened: "couldn't load" is plain without getting technical
- Offers a way through: the direct Calendly link always works
- Records the event: analytics tells me how often users hit the fallback, which is what tells me whether 8 seconds is the right timeout
Lazy Loading the Embed
The iframe itself is lazy-loaded with an IntersectionObserver, since there's no point fetching Calendly's JavaScript until the user scrolls near the booking section:
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldLoad(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(el);
return () => observer.disconnect();
}, []);rootMargin: '200px' starts loading 200px before the container enters the
viewport, so combined with the iframe's loading="lazy" attribute, the
embed begins fetching right around when the user would naturally scroll
to it.
When to Use This Pattern
Any time you embed a third-party widget that could be blocked:
- Calendly, Cal.com: scheduling
- Typeform, Tally: forms
- YouTube, Vimeo: video
- Intercom, Drift: chat widgets
The pattern is always the same: set a timeout, show a fallback with a direct link, and track how often it fires. If the fallback rate turns out high, make the direct link the primary UI and drop the embed to secondary.
