Debugging WordPress redirect behavior

When template_redirect Fires Twice

Ever seen template_redirect run twice for no reason? Here’s how browser prefetching caused it and how to fix it with one simple check.

Prefetch request by browser

If you’ve ever written a redirect in WordPress, you know it’s usually simple: check a condition, call wp_safe_redirect(), and you’re done.

That’s why it’s confusing when a redirect works perfectly one moment and fails the next.

I recently ran into that exact situation. My redirect logic looked correct, the conditions were clear, yet users were being sent to the wrong page or caught in a loop.

Even stranger, everything worked fine in Firefox but not in Chrome.

What began as a small template_redirect snippet turned into two days of debugging, and the cause wasn’t in my code at all. It was in how modern browsers “help” load pages faster through prefetching.

In this post, I’ll walk you through what happened, what I learned about speculative requests, and how a single if check fixed the problem.

The Setup: The Gating Logic

The original goal was simple — protect the checkout page so only logged-in users could access it.
Guests trying to open /checkout should be redirected to the cart page, where a login modal appears.
After logging in, they’re automatically sent back to the checkout.

Here’s the logic in code:

add_action('template_redirect', function () {

    // 1) If user is logged in and we have the gate flag → go straight to Checkout.
    if (is_user_logged_in() && isset($_GET['login_required'])) {
        wp_safe_redirect(wc_get_checkout_url());
        exit;
    }

    // 2) Guard Checkout: guests can’t open /checkout (non-AJAX, non-REST).
    if (
        ! is_user_logged_in()
        && is_checkout()
        && ! wp_doing_ajax()
        && ! defined('REST_REQUEST')
    ) {
        wp_safe_redirect(add_query_arg('login_required', '1', wc_get_cart_url()));
        exit;
    }
}, 0);

It looks straightforward.
And in most cases, it worked perfectly — the redirect did exactly what it should.

But only most cases.

In some browsers, users logged in successfully but still got pushed back to the cart page, as if the login didn’t happen at all.

The Bug: The Inconsistent Redirect

Everything looked right on paper. The redirect logic was solid, and normal logins worked exactly as expected.

The issue appeared only when users logged in via AJAX-based methods — things like social login, OTP login, or any popup login form that didn’t reload the page.

After logging in, the popup confirmed success, but instead of landing on the checkout page, users were bounced back to the cart again.

Even stranger, this happened only in Chrome.
In Firefox, the same flow worked flawlessly.

That inconsistency was the first real clue. If the PHP logic didn’t change, but browser choice did, the problem had to be happening before the code even saw the request.

It was time to start digging deeper.

The Investigation: Digging Into the Requests

After hours of testing different login methods, I decided to stop guessing and start logging — the simplest step often reveals the most.

I added this small snippet at the very top of my template_redirect callback:

if (is_checkout()) {
    error_log(print_r([
        is_user_logged_in(),
        $_SERVER
    ], true));
}

This log instantly showed something unexpected.
The hook wasn’t firing once per request—it was firing twice for the same /checkout URL.

On the first hit, is_user_logged_in() returned false.
On the second, just a moment later, it returned true.

The logs confirmed it. Both requests hit the same path, but there were two key differences:

  1. The first request had no login cookie.
  2. The first request contained a header:
HTTP_SEC_PURPOSE: prefetch

That was the turning point.
The “ghost” request wasn’t coming from WordPress or WooCommerce — it was coming from the browser itself.

The Cause: Browser Prefetch Behavior

Browser prefetch request

At first it felt like a WordPress problem, but it wasn’t.
It was the browser.

Modern browsers like Chrome, Edge, and sometimes Safari use prefetch and prerender to speed up navigation. They request pages before the user actually clicks. These speculative requests often arrive without cookies or session data for privacy and security reasons.

Here’s what happens in practice:

  • The first request looks like a normal page visit but includes a header such as HTTP_SEC_PURPOSE: prefetch. It has no wordpress_logged_in cookie.
  • WordPress sees a guest and runs your redirect logic.
  • A moment later, the real navigation follows with full cookies and the correct login state, but your redirect has already fired.

Detecting and ignoring these speculative requests fixes the issue. You stop acting on visits that aren’t real user navigations.

The Fix: Ignore Speculative Loads

The safest approach is to bail out early when the request is a prefetch/prerender.
Check for the relevant headers, and skip any redirect logic for those requests.

add_action('template_redirect', function () {
    // 0) Ignore speculative requests (prefetch / prerender).
    $is_prefetch =
        (!empty($_SERVER['HTTP_SEC_PURPOSE']) && stripos($_SERVER['HTTP_SEC_PURPOSE'], 'prefetch') !== false) ||
        (!empty($_SERVER['HTTP_PURPOSE']) && stripos($_SERVER['HTTP_PURPOSE'], 'prefetch') !== false);

    if ($is_prefetch) {
        return; // Do nothing for prefetch. Treat only real navigations.
    }

    // 1) If user is logged in and we have the gate flag → go straight to Checkout.
    if (is_user_logged_in() && isset($_GET['login_required'])) {
        wp_safe_redirect(wc_get_checkout_url());
        exit;
    }

    // 2) Guard Checkout: guests can’t open /checkout (non-AJAX, non-REST).
    if (
        ! is_user_logged_in()
        && function_exists('is_checkout') && is_checkout()
        && ! wp_doing_ajax()
        && ! defined('REST_REQUEST')
    ) {
        wp_safe_redirect(add_query_arg('login_required', '1', wc_get_cart_url()));
        exit;
    }
}, 0);

This small check prevents WordPress from acting on a request that isn’t a real visit.
Your actual navigation arrives next, with cookies, and the redirect behaves correctly.

Lessons Learned

This issue was small but revealing. It showed how something that looks like a WordPress bug can actually come from the browser.

Here are the main takeaways:

  • Browsers can send speculative requests like prefetch or prerender before the user even clicks a link.
  • Those requests often arrive without cookies or session data, which makes logged-in checks unreliable.
  • WordPress runs template_redirect on every request, even speculative ones.
  • Ignoring prefetch requests prevents false redirects and protects flows like logins, checkouts, or gated content.
  • Always log $_SERVER when behavior feels inconsistent—it often tells you more than any print_r of your variables.

The fix was a single line, but the lesson was bigger: sometimes the issue isn’t in your PHP — it’s in what the browser thinks it’s helping you with.

Share this:
devnet symbol

Devnet - web development

We specialize in creating custom WordPress and WooCommerce websites and online stores. From custom plugins and integrations to custom design and user experience, we have the expertise to bring your vision to life. We also offer ongoing support and maintenance services to ensure the smooth operation of your website.
Feel free to reach out to us – we look forward to hearing from you.