Loading
Client-Side Redirect Detection

Client-Side Redirect Detection

Overview

Detects client-side redirects that add unnecessary latency to page load and impact Core Web Vitals, particularly LCP. Client-side redirects implemented via JavaScript or SPA routers can add seconds to the critical rendering path, yet they're often invisible to standard monitoring tools.

Why this matters:

Client-side redirects are one of the most impactful but hardest-to-detect performance issues:

  • Add complete navigation overhead (HTML download, JS parsing, execution) just to redirect
  • Can increase LCP by 3 to 6 seconds on first visit
  • Waste bandwidth downloading resources that won't be displayed
  • Not visible in basic performance metrics
  • Often introduced accidentally during localization or A/B testing

Common patterns that trigger client-side redirects:

PatternExampleImpact
Language detection/ loads JS → redirects to /es/Very high - entire page load wasted
A/B testingLanding page → JS decides variant → redirectsHigh - experiment framework overhead
Authentication checkPublic URL → JS checks auth → redirects to loginModerate - blocking on auth check
Legacy URLsOld URL → JS maps to new URL → redirectsHigh - should use 301 instead
SPA routingDeep link → app loads → client router takes overLow to moderate - depends on bundle size

Real-world example:

User navigates to: https://example.com/

❌ What happens with client-side redirect:
• / responds with 2 KB HTML (757ms)
• Downloads main.js 3.4 MB (9,450ms)
• JS executes and redirects to /es/
• Downloads /es/ HTML and resources (2,430ms)
• Finally shows LCP image (6,320ms total)

✅ What should happen with server-side redirect:
• / responds with 301 redirect to /es/ (0-50ms)
• Browser navigates to /es/ directly
• Downloads /es/ HTML and resources
• Shows LCP image (1,200ms total)

Result: 81% improvement in LCP

Redirect Flow Comparison:

Snippet

// Client-Side Redirect Detection
// https://webperf-snippets.nucliweb.net

(async () => {
  const navEntries = performance.getEntriesByType('navigation');

  if (navEntries.length === 0) {
    console.log(
      '%c⚠️ Navigation Timing not available',
      'color: #f59e0b; font-weight: bold;'
    );
    return;
  }

  const navEntry = navEntries[0];
  const currentURL = new URL(window.location.href);
  const resources = performance.getEntriesByType('resource');

  // Check for document navigations (only navigation type, exclude iframes)
  // Filter out third-party iframes (analytics, ads, etc.)
  const documentNavigations = resources.filter(
    (r) => {
      if (r.initiatorType !== 'navigation') return false;

      // Only count same-origin navigations as potential redirects
      try {
        const resourceURL = new URL(r.name);
        return resourceURL.origin === currentURL.origin;
      } catch {
        return false;
      }
    }
  );

  // Check for server-side redirects
  const serverRedirects = navEntry.redirectCount || 0;
  const redirectTime = navEntry.redirectEnd - navEntry.redirectStart;

  // Detect client-side redirect patterns
  const hasSPARouter = !!window.history?.state;
  const historyLength = window.history.length;

  // Check document.referrer for same-origin navigation
  const referrer = document.referrer ? new URL(document.referrer) : null;
  const sameOrigin = referrer && referrer.origin === currentURL.origin;
  const referrerPath = referrer?.pathname || '';
  const currentPath = currentURL.pathname;

  // Detect redirect indicators
  const hasRedirectParam =
    currentURL.searchParams.has('redirect') ||
    currentURL.searchParams.has('from') ||
    currentURL.searchParams.has('origin');

  console.group(
    '%c🔄 Client-Side Redirect Detection',
    'font-weight: bold; font-size: 14px;'
  );

  // Current page info
  console.log('');
  console.log('%c📍 Current Page:', 'font-weight: bold;');
  console.log(`   URL: ${currentURL.href}`);
  console.log(`   Path: ${currentPath}`);
  if (referrer) {
    console.log(`   Referrer: ${referrer.href}`);
    console.log(`   Referrer path: ${referrerPath}`);
  } else {
    console.log('   Referrer: (none - direct navigation or blocked)');
  }

  // Server-side redirects
  console.log('');
  console.log('%c🌐 Server-Side Redirects:', 'font-weight: bold;');
  if (serverRedirects > 0) {
    console.log(`%c   ⚠️ ${serverRedirects} redirect(s) detected`, 'color: #f59e0b;');
    console.log(`   Redirect time: ${redirectTime.toFixed(1)}ms`);
    console.log('   💡 Minimize redirect chains for better performance');
  } else {
    console.log('%c   ✅ No server-side redirects', 'color: #22c55e;');
  }

  // Client-side navigation detection
  console.log('');
  console.log('%c📱 Client-Side Navigation Indicators:', 'font-weight: bold;');

  let hasClientRedirect = false;
  const indicators = [];

  // Check 1: Same-origin referrer with different path (potential redirect)
  if (sameOrigin && referrerPath !== currentPath && referrerPath !== '') {
    hasClientRedirect = true;
    indicators.push({
      type: 'Same-origin navigation',
      from: referrerPath,
      to: currentPath,
      severity: 'warning'
    });
  }

  // Check 2: Document navigations in resource timing
  if (documentNavigations.length > 0) {
    hasClientRedirect = true;
    documentNavigations.forEach((nav) => {
      indicators.push({
        type: 'Document navigation',
        url: nav.name,
        duration: nav.duration.toFixed(1) + 'ms',
        severity: 'error'
      });
    });
  }

  // Check 3: Redirect URL parameters
  if (hasRedirectParam) {
    indicators.push({
      type: 'Redirect parameter in URL',
      params: Array.from(currentURL.searchParams.entries())
        .filter(([key]) =>
          key.toLowerCase().includes('redirect') ||
          key.toLowerCase().includes('from') ||
          key.toLowerCase().includes('origin')
        )
        .map(([key, val]) => `${key}=${val}`)
        .join(', '),
      severity: 'info'
    });
  }

  // Check 4: SPA router state
  if (hasSPARouter && historyLength > 1) {
    indicators.push({
      type: 'SPA router detected',
      historyLength: historyLength,
      state: JSON.stringify(window.history.state)?.slice(0, 100) || '{}',
      severity: 'info'
    });
  }

  // Check 5: Very fast navigation with minimal content (possible JS redirect)
  const navDuration = navEntry.loadEventEnd - navEntry.startTime;
  const domContentLoaded = navEntry.domContentLoadedEventEnd - navEntry.startTime;
  const responseSize = navEntry.transferSize || navEntry.encodedBodySize || 0;

  // Only flag if it's BOTH fast AND small (likely a redirect page)
  if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) {
    indicators.push({
      type: 'Fast minimal-content navigation',
      duration: domContentLoaded.toFixed(1) + 'ms',
      size: (responseSize / 1024).toFixed(1) + ' KB',
      note: 'Small page that loads quickly - possible redirect page',
      severity: 'warning'
    });
  }

  if (indicators.length === 0) {
    console.log('%c   ✅ No client-side redirect detected', 'color: #22c55e;');
  } else {
    console.log(
      `%c   ⚠️ ${indicators.length} indicator(s) found`,
      'color: #f59e0b;'
    );
    console.log('');
    indicators.forEach((indicator, index) => {
      const icon =
        indicator.severity === 'error' ? '🔴' :
        indicator.severity === 'warning' ? '⚠️' : 'ℹ️';

      console.log(`   ${icon} ${indicator.type}`);
      Object.entries(indicator).forEach(([key, value]) => {
        if (key !== 'type' && key !== 'severity') {
          console.log(`      ${key}: ${value}`);
        }
      });
      if (index < indicators.length - 1) console.log('');
    });
  }

  // LCP Impact Analysis
  if (hasClientRedirect && documentNavigations.length > 0) {
    console.log('');
    console.log('%c⚡ Performance Impact:', 'font-weight: bold;');

    const totalRedirectOverhead = documentNavigations.reduce(
      (sum, nav) => sum + nav.duration,
      0
    );

    console.log(`   Total redirect overhead: ${totalRedirectOverhead.toFixed(0)}ms`);

    if (totalRedirectOverhead > 3000) {
      console.log(
        '%c   🔴 CRITICAL: High impact on LCP',
        'color: #ef4444; font-weight: bold;'
      );
      console.log('   💡 This redirect adds significant delay to page load');
    } else if (totalRedirectOverhead > 1000) {
      console.log(
        '%c   🟡 MODERATE: Noticeable impact on LCP',
        'color: #f59e0b;'
      );
    } else {
      console.log('%c   🟢 Low impact', 'color: #22c55e;');
    }
  }

  // Recommendations
  const hasDocumentNavigation = documentNavigations.length > 0;
  const hasSameOriginRedirect = sameOrigin && referrerPath !== currentPath && referrerPath !== '';
  const hasHighImpact = hasDocumentNavigation && documentNavigations.reduce((sum, nav) => sum + nav.duration, 0) > 1000;

  if (hasDocumentNavigation || serverRedirects > 0) {
    console.log('');
    console.log('%c💡 Recommendations:', 'font-weight: bold;');

    if (serverRedirects > 0) {
      console.log('');
      console.log('   Server-side redirects detected:');
      console.log('   • Minimize redirect chains');
      console.log('   • Use direct links when possible');
      console.log('   • Cache redirect responses with appropriate headers');
    }

    if (hasDocumentNavigation) {
      console.log('');
      console.log('   Client-side redirect detected:');
      console.log('   • Replace with server-side 301/302 redirects for better performance');

      if (referrerPath && currentPath) {
        console.log('');
        console.log('   Example Nginx config:');
        console.log('     %clocation = ' + referrerPath + ' {', 'color: #3b82f6; font-family: monospace;');
        console.log('     %c  return 301 ' + currentPath + ';', 'color: #3b82f6; font-family: monospace;');
        console.log('     %c}', 'color: #3b82f6; font-family: monospace;');

        if (referrerPath === '/' && currentPath.match(/^\/[a-z]{2}\//)) {
          console.log('');
          console.log('   Or with language detection:');
          console.log('     %cmap $http_accept_language $lang {', 'color: #3b82f6; font-family: monospace;');
          console.log('     %c  default ' + currentPath.split('/')[1] + ';', 'color: #3b82f6; font-family: monospace;');
          console.log('     %c  ~*^en en;', 'color: #3b82f6; font-family: monospace;');
          console.log('     %c}', 'color: #3b82f6; font-family: monospace;');
          console.log('     %clocation = / {', 'color: #3b82f6; font-family: monospace;');
          console.log('     %c  return 301 /$lang/;', 'color: #3b82f6; font-family: monospace;');
          console.log('     %c}', 'color: #3b82f6; font-family: monospace;');
        }
      }
    } else if (hasSameOriginRedirect) {
      console.log('');
      console.log('   Same-origin navigation detected (no document navigation overhead):');
      console.log('   • This may be SPA routing or a fast redirect');
      console.log('   • If this is intentional SPA behavior, no action needed');
      console.log('   • If this is a redirect, consider server-side 301/302 for consistency');
    }
  }

  // Detailed timing breakdown
  console.log('');
  console.log('%c⏱️ Navigation Timing:', 'font-weight: bold;');
  console.table({
    'TTFB': `${(navEntry.responseStart - navEntry.startTime).toFixed(1)}ms`,
    'DOM Content Loaded': `${domContentLoaded.toFixed(1)}ms`,
    'Load Complete': `${navDuration.toFixed(1)}ms`,
    'Redirect Time': serverRedirects > 0 ? `${redirectTime.toFixed(1)}ms` : 'N/A',
    'DNS Lookup': `${(navEntry.domainLookupEnd - navEntry.domainLookupStart).toFixed(1)}ms`,
    'TCP Connect': `${(navEntry.connectEnd - navEntry.connectStart).toFixed(1)}ms`,
    'Request/Response': `${(navEntry.responseEnd - navEntry.requestStart).toFixed(1)}ms`,
  });

  // Resource timing for document navigations
  if (documentNavigations.length > 0) {
    console.log('');
    console.log(
      '%c🔴 Same-Origin Document Navigations Detected (Potential Client-Side Redirects):',
      'font-weight: bold; color: #ef4444;'
    );
    console.log('');
    console.log(
      '   These are same-origin navigations that suggest client-side redirects.'
    );
    console.log(
      '   If these are not expected, consider replacing with server-side redirects.'
    );
    console.log('');
    console.table(
      documentNavigations.map((nav) => ({
        'Type': nav.initiatorType,
        'Duration (ms)': nav.duration.toFixed(1),
        'TTFB (ms)': (nav.responseStart - nav.startTime).toFixed(1),
        'Transfer (KB)': nav.transferSize > 0 ? (nav.transferSize / 1024).toFixed(1) : '0',
        'URL': nav.name.length > 60 ? '...' + nav.name.slice(-57) : nav.name,
      }))
    );
  }

  console.log('');

  if (hasDocumentNavigation) {
    console.log(
      '%cℹ️ Note: Document navigations detected indicate actual client-side redirects. ' +
      'For best performance, replace with server-side 301/302 redirects.',
      'color: #6b7280; font-style: italic;'
    );
  } else if (hasSameOriginRedirect) {
    console.log(
      '%cℹ️ Note: Same-origin navigation detected without document navigation overhead. ' +
      'This may be SPA routing or browser navigation, which is typically acceptable.',
      'color: #6b7280; font-style: italic;'
    );
  } else {
    console.log(
      '%cℹ️ Note: No client-side redirects detected. ' +
      'The current page was loaded directly or through server-side redirects.',
      'color: #6b7280; font-style: italic;'
    );
  }

  console.groupEnd();
})();

Understanding the Results

Current Page

Shows the current URL, path, and referrer information. If the referrer is from the same origin but a different path, this suggests a potential client-side redirect.

FieldDescription
URLFull current URL
PathCurrent pathname
ReferrerPrevious page URL (if available)
Referrer pathPrevious pathname

No referrer can mean:

  • Direct navigation (user typed URL or used bookmark)
  • Navigation from HTTPS → HTTP (browsers hide referrer)
  • Referrer blocked by Referrer-Policy

Server-Side Redirects

Detects HTTP 301/302/307/308 redirects using the Navigation Timing API.

StatusMeaning
✅ No server-side redirectsDirect navigation, optimal
⚠️ N redirect(s) detectedHTTP redirects occurred - check if necessary

Impact:

  • Each redirect adds one round-trip (50-300ms typically)
  • Redirect chains (A→B→C) multiply the cost
  • Mobile/slow connections amplify the impact

When redirects are acceptable:

  • HTTPS enforcement (HTTP→HTTPS)
  • www canonicalization (www→non-www or vice versa)
  • Temporary redirects for maintenance (302)

When to avoid:

  • Chains of 3+ redirects
  • Client-side redirects that could be server-side
  • Redirects on every page load

Client-Side Navigation Indicators

The snippet checks multiple signals to detect client-side redirects:

IndicatorWhat it detectsSeverity
Same-origin navigationReferrer from same domain, different path⚠️ Warning
Document navigationSame-origin page loads detected in Resource Timing (excludes third-party iframes)🔴 Error
Redirect parameterURL contains ?redirect=, ?from=, etc.ℹ️ Info
SPA router detectedHistory API state presentℹ️ Info
Fast minimal-content navigationSmall page (<10KB) loaded quickly (<500ms) - likely a redirect page⚠️ Warning

Severity levels:

  • 🔴 Error: High-confidence client-side redirect detection - fix immediately
  • ⚠️ Warning: Likely redirect pattern - investigate
  • ℹ️ Info: Supporting evidence - may be normal behavior

Example output:

📱 Client-Side Navigation Indicators:
   ⚠️ 2 indicator(s) found

   🔴 Document navigation
      url: https://example.com/es/
      duration: 2430.5ms

   ⚠️ Same-origin navigation
      from: /
      to: /es/

Performance Impact

When client-side redirects are detected, the snippet estimates their impact on LCP:

Impact LevelOverheadRatingAction
CRITICAL> 3000ms🔴Fix immediately - major LCP issue
MODERATE1000-3000ms🟡High priority - noticeable delay
LOW< 1000ms🟢Monitor - may be acceptable for SPAs

Calculating overhead:

The overhead is the total duration of all document navigations detected in the Resource Timing API. This represents the additional time spent loading and processing pages that were only needed to redirect the user.

Recommendations

The snippet provides actionable recommendations based on the redirect type:

For server-side redirects:

  • Minimize redirect chains
  • Use direct links in your HTML/marketing campaigns
  • Cache redirect responses

For client-side redirects:

  • Replace with HTTP 301 (permanent) or 302 (temporary)
  • Use language detection at the server/CDN level
  • Implement redirects in Nginx, Apache, or CDN config

Example server-side redirect configurations:

See your web server documentation for implementing 301/302 redirects with language detection or path mapping.

Navigation Timing Table

Provides a complete breakdown of the current page load:

MetricDescription
TTFBTime to First Byte - server response time
DOM Content LoadedWhen HTML is parsed and DOM is ready
Load CompleteWhen all resources finish loading
Redirect TimeTime spent following redirects (if any)
DNS LookupDomain name resolution time
TCP ConnectConnection establishment time
Request/ResponseTime to receive the response

Document Navigations Table

If same-origin document navigations are detected, shows detailed timing for each. These are potential client-side redirects.

Note: Third-party iframes (analytics, ads, etc.) are automatically excluded from this analysis.

ColumnDescription
Typenavigation (same-origin page navigations)
DurationTotal time from start to load complete
TTFBTime to first byte for this navigation
TransferBytes transferred over the network
URLThe navigation URL (same origin as current page)

How to Use This Snippet

1. Detect redirects on landing pages:

Navigate to your site's homepage or common landing pages and run the snippet. Look for:

  • Same-origin navigation indicators
  • Document navigation entries
  • High performance impact

2. Compare direct vs. redirect navigation:

Test 1: Navigate directly to /es/
→ Run snippet → Note timing

Test 2: Navigate to / (which redirects to /es/)
→ Run snippet → Note timing

Compare: Is Test 2 significantly slower?

3. Test in different scenarios:

  • First visit (hard reload: Cmd+Shift+R)
  • Repeat visit (normal reload: Cmd+R)
  • With Service Worker active
  • Different language preferences
  • Different geographic locations

4. Validate after fixes:

After implementing server-side redirects:

  • Retest with the snippet
  • Verify no document navigation entries
  • Confirm server-side redirects show in the "Server-Side Redirects" section
  • Check that redirect time is minimal (under 50ms)

Browser Support

FeatureChromeEdgeFirefoxSafari
Navigation Timing57125811
Resource Timing43124011
document.referrerAllAllAllAll

The snippet works in all modern browsers. Older browsers may show reduced information.

Limitations

Cannot detect:

  • Meta refresh redirects (<meta http-equiv="refresh">)
  • Redirects that happen before the page loads
  • Some SPA router navigations that don't create resource entries
  • History.pushState/replaceState navigations without full page loads

Automatically filtered out:

  • Third-party iframes (analytics, ads, social widgets)
  • Cross-origin navigations
  • Standard iframe embeds

False positives:

  • SPAs that legitimately use client-side routing
  • Preview/staging environments with redirect parameters
  • A/B testing platforms that use URL parameters

Accuracy notes:

  • Performance impact is estimated based on detected navigation entries
  • Actual LCP impact may vary based on resource loading patterns
  • Some redirect patterns may not leave traces in Performance APIs

Further Reading