Skip to main content

An 8-Second Timeout for Third-Party Iframes

An hourglass with sand flowing through it
Apr 7, 20262 min readReact, UX, Next.js

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&apos;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.