Freebo’s public checkout has a hot-lead capture hook. As the customer fills in their name, email, and phone, we POST that partial contact info to the API on a 900ms debounce so operators still get a warm lead when someone bails before paying. Standard abandoned-cart pattern. Fire-and-forget, wrapped in a .catch(), never blocks the page.
For weeks I watched real conversions come in and quietly wondered why the “abandoned but captured” bucket was so much smaller than it should be. Then I actually looked at the traffic split.
iOS is 54% of checkout traffic.
And on iOS, the debounce was quietly dropping leads on the floor.
Why the debounce loses to the app switcher
Here is the sequence you have to model in your head. A customer opens the checkout in Safari. They type their email. Nothing has been sent yet — the 900ms debounce timer is running. They tab out to Mail to copy the address they want to use. They swipe back.
Except they don’t always swipe back. Sometimes a text arrives, or they take a call, or they just move on. And the moment their tab was backgrounded, iOS Safari did a specific, aggressive thing: it froze all JavaScript in the document and killed every in-flight fetch with it. The 900ms timer never got the chance to fire. The lead never got sent. Nothing errored — the request simply ceased to exist mid-thought.
This isn’t a bug. It’s the platform. iOS is protecting battery and memory, and any web analytics pattern that assumes “requests eventually finish” is wrong there. On the web, “abandonment” means the customer navigated away. On iOS, abandonment means they looked at their phone differently for a second.
On iOS, an event listener is the last callback guaranteed to run before the page is frozen. Not the request. The callback.
— The rule
The teardown signal you actually get
Two browser events remain reliable in this failure mode: pagehide and visibilitychange firing with state hidden. Both are needed, and neither is redundant.
beforeunload is not on that list. iOS Safari does not fire it reliably at all, and thanks to the back/forward cache it often skips unload entirely too. pagehide is the only dependable teardown signal on iOS. visibilitychange fires earlier — on app switch, screen lock, and tab switch — which is where most real abandonment happens, and it is the last callback guaranteed to run before the freeze.
So the hot-lead hook subscribes to both and flushes whatever is pending:

The pending payload is staged inside a useRef on every keystroke, so the flush always has something to send even if the debounce never fired. When the customer comes back and keeps typing, the timer reschedules; when they leave, the flush wins.
keepalive, not sendBeacon
navigator.sendBeacon is the obvious tool for “please send this after the page goes away.” It’s a one-line API and every guide points to it.
I used fetch with keepalive: true instead. Not because I like typing more, but because sendBeacon would break the CORS contract the server already serves.
sendBeacon downgrades the request body to a CORS-safelisted content-type — text/plain, multipart/form-data, or application/x-www-form-urlencoded. My endpoint expects application/json and validates the body with Zod. Switching to sendBeacon would mean rewriting the body parser server-side to accept a form-encoded fallback that only ever fires from the flush path, doubling the surface area of an endpoint that has to be bulletproof because it can never surface errors to the user.
fetch with keepalive: true keeps everything — the auth headers, the JSON content-type, the exact request the debounce would have sent had it fired. keepalive requests are allowed to outlive the document, which is the entire point.
There is a small tax: the keepalive budget is 64KB per document across all in-flight keepalive requests, not per request. Once every send uses keepalive (not just the flush), a stalled network plus fast typing against a 900ms debounce could theoretically exhaust it. A payload is 2–6KB with UTM capped, so it’d take 10–30 concurrent inflight to hit — unlikely in the wild, but no longer structurally impossible. If it happens, fetch rejects, .catch() swallows, and one lead is silently dropped. Never a throw into the page.
The fix that made things worse
Here is the trap I walked into. I shipped the flush. iOS captures shot up. Then I looked at the API logs and the endpoint’s 400 rate had climbed to almost 20%.
The flush was doing exactly what it was designed to do — sending whatever was pending at the moment the customer left. Which, during abandonment, is very often a half-typed email. bob@g fails Zod’s z.string().email() validator server-side. The endpoint returned 400. The client’s .catch() swallowed it. Nothing surfaced, but the lead was still lost.
The old presence-only gate — “if email is a non-empty string, send it” — had always been the problem. It was just invisible until the flush started firing it on abandonment specifically.
The fix is a client-side validity gate that mirrors the server’s rules closely enough to catch the half-typed cases, but not so exhaustively that it re-implements RFC 5322 in the browser:
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/;
function validEmail(email: string | undefined): string | undefined {
const e = email?.trim();
return e && EMAIL_RE.test(e) ? e : undefined;
}
The {2,} TLD is not cosmetic. bob@gmail.c is one keystroke short of a real address and is exactly where a 900ms typing pause lands. A looser [^\s@]+ tail would stage it as pending, and the flush would then send it and take a 400 — losing the very lead this hook exists to save.
The reverse direction is safe: Zod’s accepted set is a strict subset of this regex, so this gate cannot silently reject an address the server would have taken.
Email and phone are judged independently, so a valid phone still captures the lead when the email is mid-typing. Old code sent both and took a 400 for the whole payload. New code sends the valid half.
The third teardown path nobody talks about
pagehide and visibilitychange cover the page going away. They do not cover an SPA route change (product page → payment page) or an embedded iframe being torn down. React just unmounts the component and neither browser event fires.
So there’s a third flush, on unmount:

It’s StrictMode-safe. The immediate remount’s cleanup finds nothing pending.
What actually shipped
The whole thing is one hook, useHotLeadCapture, exposing capture(input) for the debounced typing path and captureNow(input) for immediate step transitions (payment loaded, converted, failed). Everything else — the flush, the listeners, the pending ref, the keepalive fetches, the validity gates, the unmount cleanup — is internal.
Callers see a boring hook. iOS customers get their leads captured. The 400 rate is back down. The 54% of traffic that used to be invisible now shows up in the operator’s leads inbox.
The takeaway
If your abandoned-cart tracking works “fine” in Chrome desktop and you’ve never checked what happens on a real iPhone in real traffic, assume you’re losing the majority of your mobile leads to the app switcher. Not to the app switcher, exactly — to the freeze that follows the app switcher.
Three rules that keep coming up:
- On iOS the last thing to run before the freeze is a
visibilitychangehandler. If your data isn’t already in afetch(..., { keepalive: true })before that handler returns, it’s gone. sendBeaconlooks like the right tool and isn’t, if you care about content-type or auth headers.fetchwithkeepalive: truecosts nothing and preserves the request.- Any change that sends more data on the abandonment path will expose bugs in your payload validation. Fix the validation before shipping the flush, or ship them together.
Fire-and-forget only works if the fire actually starts. On iOS, without a flush, it often doesn’t.