The 2019 redesign redirected /blog/post to /articles/post. The 2021 HTTPS move redirected http:// to https://. The 2023 rebrand added www. Nobody noticed that an old backlink now went through four redirects, until one of the middle rules got deleted in a config cleanup and the link died.

Redirect chains aren't broken links yet. They're broken links on a timer.

TL;DR

  • A chain is any redirect that lands on another redirect. Every migration adds a hop.
  • Chains cost latency on every visit, dilute crawling, and break when any single hop is removed.
  • Find them by following redirects manually and recording each hop. Script below.
  • Fix them by pointing every source straight at the final URL, and by updating your own internal links so they don't redirect at all.
  • My opinion: internal links should have zero hops. Redirects are for the outside world.

What a chain looks like

$ curl -sIL http://example.com/blog/post | grep -iE '^(HTTP|location)' HTTP/1.1 301 Moved Permanently Location: https://example.com/blog/post HTTP/2 301 location: https://www.example.com/blog/post HTTP/2 301 location: https://www.example.com/articles/post HTTP/2 301 location: https://www.example.com/articles/post/ HTTP/2 200 

Four hops. Each one is a separate round trip for the browser, often including a new TLS handshake when the hostname changes. On mobile that can add a second or more before anything renders.

The last hop is the trailing slash, which is the sneakiest source of extra redirects. Trailing slashes: the small URL detail that creates redirects goes deeper on that one.

Why chains hurt

  • Speed: every hop is a full request before the real page even starts.
  • Crawling: search engines follow a limited number of hops (Google says it follows up to 10, but recommends fewer) and each hop is a wasted fetch.
  • Fragility: a chain of four depends on four rules surviving every future config change.
  • Debugging: when a link breaks, nobody remembers which rule was the middle one.

A script that maps every hop

This follows redirects one at a time, so you see the whole path instead of just the end:

import sys, urllib.parse, requests def trace(url, max_hops=10): hops = [] seen = set() for _ in range(max_hops): r = requests.head(url, allow_redirects=False, timeout=10, headers={"User-Agent": "chain-check/1.0"}) if r.status_code == 405: # some servers reject HEAD  r = requests.get(url, allow_redirects=False, timeout=10, stream=True) hops.append((r.status_code, url)) loc = r.headers.get("Location") if r.status_code not in (301, 302, 303, 307, 308) or not loc: return hops url = urllib.parse.urljoin(url, loc) if url in seen: hops.append(("LOOP", url)) return hops seen.add(url) hops.append(("TOO_MANY", url)) return hops for line in sys.stdin: start = line.strip() if not start: continue hops = trace(start) redirects = sum(1 for s, _ in hops if isinstance(s, int) and 300 <= s < 400) if redirects >= 2 or hops[-1][0] in ("LOOP", "TOO_MANY"): print(f"\n{redirects} hops: {start}") for status, u in hops: print(f" {status} {u}") 

Feed it URLs from your sitemap, an old sitemap from the Wayback Machine, or a backlink export:

curl -s https://example.com/sitemap.xml | grep -oP '(?<=<loc>)[^<]+' | python chains.py 

The old sitemap is the most valuable input. Those URLs are exactly the ones external sites still link to.

How to flatten a chain

  1. Find the final URL for each source (the script gives you that).
  2. Rewrite each rule to point straight to the final destination. In nginx:
# before: three separate rules creating a chain # after: one hop from every legacy form location = /blog/post { return 301 https://www.example.com/articles/post/; } 
  1. Canonicalise scheme and host in one hop. A single rule that sends http://example.com/* straight to https://www.example.com/* avoids the classic http, then https, then www double hop.
  2. Update internal links. Your nav, footer, sitemap and article bodies should point at the final URL. Redirects for your own links are self-inflicted latency.
  3. Keep the old rules. Flattening means changing where they point, not deleting them.

If you're about to change a slug, plan this before you ship, as described in Changed a URL slug? A cleanup plan for every link that pointed to it.

301 or 308 for the flattened rule?

For normal page moves, 301 is fine and universally understood. Use 308 if the URL receives POST requests that must keep their method. Don't use 302 for permanent moves. That debate has its own post: 301 vs 302 redirect: which one to use for SEO.

Keep it from coming back

Chains come back with every migration. A crawler that follows links and reports redirecting internal links will catch new ones early. Running a regular crawl with linkbruno.com, or the script above in CI, turns "we'll clean up redirects someday" into a list you can actually close. The worst version of a chain, the one that loops back on itself, gets its own post next: how to find redirect loops before users hit ERR_TOO_MANY_REDIRECTS.

My take

Redirects are a compatibility layer for links you don't control: old backlinks, bookmarks, emails. They are not a substitute for fixing links you do control. Any internal link that returns a 3xx is a small bug, and a site with hundreds of them is slower and more fragile than it needs to be.

Over to you

How many hops do you tolerate before you fix a chain: 1, 2, or "whatever works"? And does anyone actually audit old sitemaps after migrations, or is that just me?