Technology Apr 22, 2026 · 4 min read

Why Safari Said 'Link Not Found' (And Chrome Didn't)

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 — inte...

DE
DEV Community
by Anand Rathnas
Why Safari Said 'Link Not Found' (And Chrome Didn't)

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:

  1. Mount → call handleRedirect() via useEffect
  2. handleRedirect() resolves the URL via API
  3. On success, call safeRedirect(targetUrl) (sets window.location.href)
  4. After redirect, update state: markVisitedInSession() sets hasVisitedInSession from falsetrue
  5. handleRedirect was wrapped in useCallback with hasVisitedInSession as 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 + useEffect dependencies 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.
  • useRef is 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.

DE
Source

This article was originally published by DEV Community and written by Anand Rathnas.

Read original article on DEV Community
Back to Discover

Reading List