← All articles
Technical SEO10 min read

Redirect Chains: How to Find Them and Why They Cost You

Redirect chains cost latency and crawl efficiency long before they cost link equity. How they form, how to trace them with curl, and how to flatten them.


A redirect chain is what happens when nobody ever deletes a rule. Each migration, each protocol upgrade, each "let's standardise on trailing slashes" ticket adds one more hop on top of the last, and because every hop still resolves to the right page in a browser, nothing ever looks broken. The cost shows up somewhere else: in mobile page load times measured in whole seconds, in crawlers giving up before they reach the end, and in a rewrite config that nobody dares touch. This is how chains form, how to see them from a terminal, and how to flatten them without breaking the URLs people have bookmarked.

Chain versus loop

A redirect chain is two or more sequential redirects between the URL requested and the URL that finally returns 200:

GET  http://example.com/old-page
 301 http://example.com/old-page      -> https://example.com/old-page
 301 https://example.com/old-page     -> https://www.example.com/old-page
 301 https://www.example.com/old-page -> https://www.example.com/new-page/
 200 https://www.example.com/new-page/

Three hops. Every one of them is individually defensible — force HTTPS, force www, honour the 2023 URL rename — and together they are a bug.

A redirect loop is a chain that never terminates, because some hop points back at a URL already visited:

GET  https://example.com/products
 301 https://example.com/products  -> https://example.com/products/
 301 https://example.com/products/ -> https://example.com/products
 301 ... forever

Browsers cut this off after roughly 20 hops and show ERR_TOO_MANY_REDIRECTS. Googlebot gives up considerably sooner — Google's documentation describes following up to five redirect hops in a single crawl attempt, then dropping the request and possibly retrying in a later crawl cycle. The page in a loop is simply not indexable. It is the one redirect failure that is genuinely fatal rather than merely expensive.

Loops are almost always two pieces of infrastructure disagreeing. A CDN edge rule that strips trailing slashes plus an app router that adds them is the classic. So is a load balancer terminating TLS and forwarding plain HTTP to an origin whose force-HTTPS rule can't see X-Forwarded-Proto, so it redirects again.

What chains actually cost

Let's be honest about this, because a lot of published advice on redirect chains is a decade out of date.

Link equity: mostly not the problem any more

The old claim is that each 301 leaks 10-15% of PageRank, so a three-hop chain bleeds a third of the value. Google has repeatedly said this is no longer how it works: PageRank is not damped by 30x redirects, and hasn't been for years. Gary Illyes and John Mueller have both stated this publicly and consistently. Treat "link juice loss through redirects" as folklore.

Two honest caveats. First, a chain that terminates in a 404 or a soft 404 loses everything — not because of the hops but because there is nothing at the end to consolidate onto. Second, signal consolidation still takes time; Google must crawl every hop before it can attribute the destination correctly, and a chain multiplies the number of crawl events needed before that happens. So the equity argument survives as a timing argument, not a leakage argument. After a migration, flat redirects consolidate faster than chained ones.

Latency: this is the real cost

Every hop is a full network round trip: DNS (if the host changes), TCP handshake, TLS handshake, request, response. On a fast desktop connection a hop might cost 50-100 ms. On a 4G mobile connection with 100+ ms of RTT and a fresh TLS negotiation per new hostname, three hops can easily add 600-900 ms before a single byte of HTML is downloaded.

That delay lands on Time to First Byte and Largest Contentful Paint, and it lands worst exactly where you least want it: on inbound links from other sites, email and social, which are the requests most likely to hit an old URL.

Worth knowing: hops to the same origin over HTTP/2 or HTTP/3 reuse the existing connection and cost far less than cross-origin hops. The example.comwww.example.com hop crosses hostnames, so it usually pays for a fresh connection. Chains that change host are the expensive ones.

Crawl efficiency

Every hop is a separate fetch counting against the host's crawl allocation. Irrelevant for a 400-page site. For a catalogue where each of 200,000 product URLs redirects twice before resolving, you have tripled the requests needed to crawl it, which directly delays discovery of new content.

The related failure is internal links pointing at redirect sources. If your nav links to /category/shoes and that 301s to /category/shoes/, every crawl of every page containing that nav generates a redundant hop. Fixing internal links usually beats fixing the rules, and it's a mechanical find-and-replace.

How chains accumulate

Chains are sedimentary. They build in layers, and you can usually date each one.

Layer 1 — protocol. The site moves to HTTPS. A rule is added: http://*https://*.

Layer 2 — hostname. Someone decides on canonical www (or canonical apex). Another rule: https://example.com/*https://www.example.com/*. Requests arriving at http://example.com/x now take two hops, because rule 1 fires first and produces a URL that rule 2 must then fix.

Layer 3 — trailing slash. The framework normalises. /* without a trailing slash → /*/. Three hops.

Layer 4 — the content migration. /blog/2019/some-post//articles/some-post/. Four hops for anything linked from before the replatform.

Layer 5 — the second content migration, two years later, mapping /articles/* to /resources/* without ever revisiting layer 4's map. Five hops, and now you're at the edge of what Googlebot will follow in one pass.

The structural mistake is that each layer was written as a transform on the current input rather than a map to the final destination. A rule that says "add https" is composable and therefore chainable. A rule that says "this exact old URL goes to this exact final URL" is not.

Finding chains with curl

curl -IL is the whole toolkit. -I sends a HEAD request and prints headers only; -L follows redirects and prints the headers of every hop in sequence.

curl -IL http://example.com/old-page
HTTP/1.1 301 Moved Permanently
Location: https://example.com/old-page
Server: nginx

HTTP/2 301
location: https://www.example.com/old-page

HTTP/2 301
location: https://www.example.com/new-page/

HTTP/2 200
content-type: text/html; charset=utf-8

Read it bottom-up: the last block is the destination, and the number of Location: headers above it is the number of hops. Anything above one Location is a chain.

Some servers behave differently for HEAD than for GET — misconfigured routers occasionally 405 on HEAD, or skip a redirect a GET would trigger. When output looks suspicious, use a real GET with the body discarded:

curl -sL -o /dev/null -D - http://example.com/old-page

For a compact chain summary across a list of URLs, ask curl to print just the status and destination of each hop with --write-out:

curl -sL -o /dev/null \
  -w '%{num_redirects} hops -> %{url_effective} (%{http_code}) in %{time_total}s\n' \
  http://example.com/old-page
3 hops -> https://www.example.com/new-page/ (200) in 0.847s

That time_total figure is the argument you take to whoever owns the infrastructure. Run it over a file of URLs:

while read -r url; do
  curl -sL -o /dev/null \
    -w "%{num_redirects}\t%{http_code}\t$url\t-> %{url_effective}\n" "$url"
done < urls.txt | sort -rn

Sorted descending, the worst chains float to the top.

Detecting loops

Cap the follow count and look for exhaustion rather than a 200:

curl -sIL --max-redirs 10 -o /dev/null -w '%{num_redirects} %{http_code}\n' \
  https://example.com/products
curl: (47) Maximum (10) redirects followed

That error, rather than a status line, is your loop detector. If you get 10 000 — ten hops and a 000 code — you have the same thing. Note that a loop can be conditional: it may only fire for a specific user agent, a specific Accept-Language, or when a session cookie is absent. Reproduce with the crawler's identity before concluding the URL is fine:

curl -sIL -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  --max-redirs 10 https://example.com/products | grep -E '^(HTTP|location)'

Cookie-dependent loops are especially nasty because they never affect a logged-in developer testing in their own browser.

Picking the right status code

Code Meaning Method preserved? Use when
301 Moved Permanently No — may rewrite POST to GET The URL has permanently changed. The default for SEO migrations.
302 Found (temporary) No — may rewrite POST to GET Genuinely temporary: A/B test, maintenance page, short campaign.
307 Temporary Redirect Yes Temporary redirect that must preserve POST bodies — form submissions, APIs.
308 Permanent Redirect Yes Permanent redirect that must preserve method. Modern, strict equivalent of 301.

The 301/302 pair predates any requirement to preserve the request method, and browsers historically converted POST to GET when following them. 307 and 308 exist to close that ambiguity: they mandate that method and body carry through.

For search, the practical position is that Google treats 301 and 308 equivalently as permanent signals, and 302 and 307 equivalently as temporary ones. It also says that a 302 left in place indefinitely will eventually be treated as permanent, because the observed behaviour contradicts the declared intent. Don't rely on that — it's a recovery mechanism, not a design.

Two things that are not HTTP redirects but are read as similar signals: <meta http-equiv="refresh"> and JavaScript location.replace(). Both work, both are slower, both are ambiguous about permanence. Use them only where you genuinely cannot emit a header, and never with a non-zero delay — that renders the intermediate page first.

Flattening

The fix is conceptually trivial and organisationally annoying: every redirect source must point directly at a URL that returns 200.

Start from the map, not the rules. Export every redirect source you have — nginx.conf, _redirects, next.config.js, the CMS plugin table, Cloudflare rules — into one list. For each source, resolve it with curl -sL -o /dev/null -w '%{url_effective}' and rewrite that rule's target to the resolved URL. You are replacing a composition of transforms with a lookup table.

Concretely, in nginx, this is the chained version:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;   # hop 1: adds TLS, keeps host
}

server {
    listen 443 ssl;
    server_name example.com;
    return 301 https://www.example.com$request_uri;  # hop 2: adds www
}

And this is the flattened one — a single rule that normalises protocol and host together:

server {
    listen 80;
    listen 443 ssl;
    server_name example.com;
    return 301 https://www.example.com$request_uri;   # one hop, always
}

server {
    listen 80;
    server_name www.example.com;
    return 301 https://www.example.com$request_uri;   # one hop, always
}

server {
    listen 443 ssl;
    server_name www.example.com;
    # actual application
}

The principle generalises: normalise all dimensions in a single response rather than one dimension per response. If you also enforce trailing slashes, that decision belongs in the same return statement, computed once.

For path-level migration maps, keep them in a data file rather than in accumulated conditional rules, and re-resolve the file whenever you add to it:

// next.config.js — permanent: true emits 308
const redirects = require('./redirects.json'); // [{ source, destination }]
module.exports = {
  async redirects() {
    return redirects.map(r => ({ ...r, permanent: true }));
  },
};

When you add a new mapping, run every existing destination through the new map. If any existing destination is now a source, rewrite it to the final target in the file. That one discipline prevents layers 4 and 5 from ever forming. Framework-specific traps around this are worth reading up on separately — Next.js SEO problems covers how routing conventions produce redirects you didn't write.

Then fix the internal links. A chain that only external traffic can trigger is a minor cost; a chain your own navigation triggers on every page view is a systemic one. Grep your templates for the old paths and update them, and update your XML sitemap so it contains only 200 URLs. A sitemap full of redirect sources is a standing instruction to a crawler to waste its time. Check your canonical tags at the same time — a canonical pointing at a redirect source is the same bug wearing a different hat.

The verification pass

After flattening, verify from the outside rather than trusting the config. For each old URL that mattered:

curl -sIL -o /dev/null -w '%{num_redirects}\t%{http_code}\t%{url_effective}\n' "$url"

You want 1 in the first column for any legacy URL and 0 for any current one, with 200 in the second. Anything else is a rule you haven't finished.

One caution: do not remove the old redirects once they're flat. The goal is one hop, not zero rules. External links and bookmarks don't expire, and deleting a redirect turns a mild inefficiency into a hard 404.

For a single important URL, SEO Fix Agent audits the live page — resolved status, canonical, robots directives and about thirty other checks — and compiles whatever fails into a prompt you can paste into a coding agent to write the config change. It audits one page per run, so for sweeping a whole URL export it complements rather than replaces the curl loop above — or a full crawler, as compared in Screaming Frog vs cloud auditors.

Quick checklist

  • No URL should take more than one hop to reach a 200.
  • Normalise protocol, host and trailing slash in a single redirect response, not in sequence.
  • Internal links, canonical tags and sitemap entries must point at final destinations.
  • Use 301/308 for permanent, 302/307 for temporary; use 307/308 when the request method must survive.
  • Test with the crawler user agent and without cookies — conditional loops hide from logged-in developers.
  • curl -sIL --max-redirs 10 and watch for curl: (47). That's your loop alarm.
  • Keep the old rules forever. Just make each one a single hop.
#redirects#http#crawl-budget#site-migration#technical-seo

Audit your page, then ship the fix

SEO Fix Agent runs 30+ technical, content and AI-search checks on a page, then compiles every finding into a severity-ordered prompt your coding agent can execute. 75 free credits, no card.

Start free →

Keep reading