This article was originally published on Jo4 Blog.
If you build a URL shortener and your links show "Link Not Found" on Safari, you have approximately zero seconds before your support inbox catches fire.
That's what happened to us. Chrome, Firefox, Edge — all fine. Safari on iOS and macOS — intermittent "This link could not be found or has expired." For a product whose entire job is redirecting links, this was existential.
The Symptom
Users would tap a short link on their iPhone. Instead of landing on the destination, they'd see our error page flash briefly — "Link Not Found" — then the correct page would load a moment later.
Some users saw it every time. Some never saw it. The flash was fast enough that screenshots were hard to capture. We couldn't reproduce it consistently on desktop Safari.
The only consistent signal: it never happened on Chrome.
Laying Breadcrumbs
We couldn't reproduce it reliably, so we shipped debug instrumentation. A useRef array that logged every function call with timestamps, persisted to localStorage so it survived the page navigation that was about to happen.
The debug output from a user's device told the story:
[0] { event: "handleRedirect called", timestamp: 1709683200001 }
[1] { event: "API success, redirecting", timestamp: 1709683200250 }
[2] { event: "handleRedirect called", timestamp: 1709683200252 }
[3] { event: "API error: request cancelled", timestamp: 1709683200260 }
Entry [2] was the smoking gun. handleRedirect was being called twice. The second call happened 2 milliseconds after the first one successfully redirected.
The Root Cause
Here's what our RedirectPage component did:
- Mount → call
handleRedirect()viauseEffect -
handleRedirect()resolves the URL via API - On success, call
safeRedirect(targetUrl)(setswindow.location.href) - After redirect, update state:
markVisitedInSession()setshasVisitedInSessionfromfalse→true -
handleRedirectwas wrapped inuseCallbackwithhasVisitedInSessionas a dependency
Step 5 is where it breaks. When the state changes, React creates a new handleRedirect callback identity. The useEffect sees a new dependency and re-fires the callback.
But wait — we already navigated away. Why does the second call matter?
Because Safari doesn't stop.
Chrome: When you set window.location.href, Chrome immediately halts JavaScript execution on the current page. The navigation takes over. The second handleRedirect call never happens.
Safari: When you set window.location.href, Safari continues executing JavaScript while the navigation is in progress. The second handleRedirect fires, starts an API request, but the page is mid-navigation. The HTTP request gets cancelled. The catch block runs. The error page renders.
The user sees: flash of "Link Not Found" → destination page loads.
The Fix
Five lines:
const hasRedirected = useRef(false);
const handleRedirect = useCallback(async (pwd?: string) => {
if (hasRedirected.current && !pwd) return; // Guard
// ... resolve URL, check previews, etc ...
hasRedirected.current = true; // Set before triggering navigation
safeRedirect(targetUrl, '/');
}, [/* deps */]);
A useRef — not useState — because we specifically don't want to trigger a re-render. The ref persists across renders, survives the callback identity change, and silently blocks the second call.
The && !pwd clause allows retries for password-protected URLs where the user submits a password.
Why useRef Instead of useState
This is the subtle part. If we'd used useState for the guard:
const [hasRedirected, setHasRedirected] = useState(false);
Setting it to true would trigger another re-render, which could create another callback identity change, which could re-fire the effect again. We'd be fighting React's render cycle with React's render cycle.
useRef sidesteps the entire problem. It mutates silently. No render. No new callback. No re-fire.
What We Removed
After confirming the fix worked across Safari, Chrome, and Firefox, we stripped out the debug instrumentation — the localStorage logging, the debug panel on the error page. It served its purpose.
But we kept the useRef guard. It's three lines of defense against a browser behavior difference that no amount of testing would have caught in CI.
Lessons Learned
-
Browsers diverge on navigation behavior. Chrome halts JS on
window.location.href. Safari doesn't. This isn't a bug in either browser — the spec doesn't mandate when to stop execution. -
useCallback+useEffectdependencies can create invisible re-execution loops. If your callback updates state that's in its own dependency array, you have a loop. It's just usually invisible because the page navigated away before it matters. - Ship debug instrumentation to production. When you can't reproduce a bug locally, instrument the code path, ship it behind a flag or with minimal overhead, and let the user's device tell you what happened.
-
useRefis your escape hatch from React's reactivity. When you need to track state that should NOT trigger renders, refs are the right tool.
Ever been bitten by a Safari-specific JS behavior? What was it? Drop it in the comments.
Building jo4.io — a URL shortener that works on every browser. Yes, even Safari.
This article was originally published by DEV Community and written by Anand Rathnas.
Read original article on DEV Community