# Redirect chains: how many hops Google follows, and how to find and flatten them

> Googlebot follows up to 10 redirect hops, but Google asks for one. Find redirect chains site-wide with curl, flatten your redirect map and fix your links.

Source: https://seofix.ai/blog/redirect-chains  
Published: 2026-11-11  
Updated: 2026-11-02  
Author: SEOFix Team  
Category: Technical SEO  
Tags: Technical SEO, Guides  
Length: 1,857 words, 9 min read

A redirect chain is a URL that redirects to another URL that redirects again before you reach a page. Google's crawlers [follow up to 10 redirect hops by default](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes), so a three-hop chain still gets indexed, but Google's own [site move guide](https://developers.google.com/search/docs/crawling-indexing/site-move-with-url-changes) asks you to redirect "to the final destination directly" and, where that isn't possible, to keep chains "ideally no more than 3 and fewer than 5". The fix is the same on every stack: point every old URL straight at its final `200` URL in one hop, and update your links, sitemap and canonicals so nothing on your site needs the redirect at all.

This post covers finding chains in bulk and flattening them. If Search Console already shows **Redirect error** (a loop, more than 10 hops or a broken `Location`), start with Redirect error in Search Console, which has a hop-by-hop trace script. If it shows **Page with redirect**, that's usually fine: see Page with redirect in Search Console.

## How many redirects Google follows

From Google's [HTTP status code documentation](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes), checked in November 2026:

- "By default, Google's crawlers follow up to 10 redirect hops. However, specific products' crawlers may have different limits." The same page notes that "Google Inspection Tools doesn't follow redirects."
- "Any content Google receives from the redirecting URL is ignored, and the final target URL's content is processed instead."
- `301` and `308` are a strong signal that the target should be processed; `302`, `303` and `307` a weak one.

The [site move guide](https://developers.google.com/search/docs/crawling-indexing/site-move-with-url-changes) adds the reason to go below 10: "Chaining redirects adds latency for users, and not all user agents and browsers support long redirect chains." HTTP itself has history here. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) notes that an earlier version of the spec "recommended a maximum of five redirections" and that "some clients might implement such a fixed limitation."

And chains cost crawl time. Google's [crawl budget guide](https://developers.google.com/crawling/docs/crawl-budget) lists "Avoid long redirect chains, which have a negative effect on crawling" among the things that help large sites.

A chain can also mix status codes. If one hop in a chain is a `302` or `307`, that hop is a weak canonical signal by Google's description, even when every other hop is a `301`. A chain built over years often mixes both. The 301 vs 302 vs 307 vs 308 post covers which status each framework sends by default.

## Where redirect chains come from

Nobody writes a chain on purpose. They build up as rules stack:

1. **Host and scheme normalisation in separate rules.** `http://example.com/shoes` goes to `https://example.com/shoes` (rule 1), then to `https://www.example.com/shoes` (rule 2), then to `https://www.example.com/shoes/` (rule 3, trailing slash). Three hops before any content redirect even runs.
2. **Migrations on top of migrations.** A 2019 redesign redirected `/products/shoes` to `/shoes`. A 2023 restructure redirected `/shoes` to `/footwear`. Old backlinks to `/products/shoes` now take two hops.
3. **Two layers that each normalise.** Next.js redirects `/about/` to `/about` by default ([trailingSlash docs](https://nextjs.org/docs/app/api-reference/config/next-config-js/trailingSlash)). If a CDN or nginx in front forces `www` and HTTPS, a single request can pick up hops from each layer.
4. **Slug changes in a CMS.** Each rename adds a redirect from the previous slug. Rename a page three times and the first slug is three hops away unless the CMS rewrites old redirects.
5. **Internal links to old URLs.** Not a chain on the server, but every internal link to `/shoes` adds a hop that didn't need to exist.

## Find redirect chains on one URL

`curl -sIL` prints the headers of every response it follows. Filter the status lines and `Location` headers:

```bash
curl -sIL http://example.com/products/shoes | grep -iE '^(HTTP|location)'
```

```
HTTP/1.1 301 Moved Permanently
Location: https://example.com/products/shoes
HTTP/2 301
location: https://www.example.com/products/shoes
HTTP/2 301
location: https://www.example.com/shoes
HTTP/2 308
location: /footwear
HTTP/2 200
```

Four hops. `-I` sends `HEAD` requests, and a few servers answer `HEAD` differently from `GET`. If the output looks odd, repeat with `-o /dev/null -D -` instead of `-I` to send `GET`, which is what Googlebot uses for pages.

Test the variants too: `http` and `https`, with and without `www`, with and without the trailing slash. Your browser has long since cached the canonical form, so you won't notice a chain that only appears on `http://example.com/shoes`.

## Find chains across a whole site

One URL at a time doesn't scale. Feed curl a list instead. Good inputs, in order of value:

- old URLs from your redirect map (every source in `.htaccess`, nginx config, `next.config.js` or the CMS redirect plugin);
- URLs that have backlinks (your backlink tool's "top pages" export);
- the URLs in Search Console's **Page with redirect** export;
- every `href` in your own HTML (from a crawl).

This script sends one `GET` per hop, records the number of redirects, the final status and URL, and the status of every hop. `%{num_redirects}` and `%{url_effective}` are curl's own [write-out variables](https://curl.se/docs/manpage.html). `--max-redirs 10` matches Google's limit, so anything that reaches 10 and still answers `3xx` is a chain Google won't finish either.

```bash
#!/usr/bin/env bash
# chains.sh urls.txt > chains.tsv
# One GET per hop. Prints: start URL, redirects, final status, final URL, status of every hop.
while IFS= read -r url; do
  curl -sL --max-redirs 10 -o /dev/null -D - \
       -w 'RESULT %{num_redirects}\t%{http_code}\t%{url_effective}\n' "$url" |
  awk -v u="$url" '/^HTTP\//{hops = hops $2 " "}
                   /^RESULT /{sub(/^RESULT /, ""); print u "\t" $0 "\t" hops}'
  sleep 0.5   # stay polite with your own server
done < "$1"
```

Output on a test server:

```
http://127.0.0.1:8765/a      3   200  http://127.0.0.1:8765/final/  301 302 301 200
http://127.0.0.1:8765/old    1   200  http://127.0.0.1:8765/final/  308 200
http://127.0.0.1:8765/about  0   200  http://127.0.0.1:8765/about   200
http://127.0.0.1:8765/loop1  10  301  http://127.0.0.1:8765/loop1   301 301 301 301 301 301 301 301 301 301 301
http://127.0.0.1:8765/gone   0   404  http://127.0.0.1:8765/gone    404
```

Then sort out what needs work:

```bash
# chains: two or more redirects, worst first
awk -F'\t' '$2 >= 2' chains.tsv | sort -t$'\t' -k2,2nr

# chains that contain a temporary hop
awk -F'\t' '$2 >= 2 && $5 ~ /(302|303|307) /' chains.tsv

# redirects that end in an error (a redirect to a 404 is a broken link with extra steps)
awk -F'\t' '$2 >= 1 && $3 >= 400' chains.tsv
```

`/loop1` hit the limit while still answering `301`: that's a loop, and the redirect error post covers how to break it.

## Flatten the redirect map

Most chains live in a redirect map: a CSV, a CMS table or a long list of rules. The fix is to resolve every source to its final target before you deploy. This Python script reads `source,target` rows, follows each source through the map, writes one row per source pointing at the end of its chain, and reports loops:

```python
# flatten_redirects.py redirects.csv > flattened.csv
import csv, sys

rules = {}
with open(sys.argv[1], newline="") as f:
    for row in csv.DictReader(f):
        rules[row["source"].strip()] = row["target"].strip()

out = csv.writer(sys.stdout)
out.writerow(["source", "target", "hops_before"])
for source in rules:
    path, seen = [source], {source}
    while path[-1] in rules:
        nxt = rules[path[-1]]
        if nxt in seen:
            print(f"LOOP: {' -> '.join(path + [nxt])}", file=sys.stderr)
            break
        path.append(nxt)
        seen.add(nxt)
    else:
        if len(path) > 2:
            print(f"chain of {len(path) - 1}: {' -> '.join(path)}", file=sys.stderr)
        out.writerow([source, path[-1], len(path) - 1])
```

Given this map:

```
source,target
/2019/shoes,/shoes
/shoes,/footwear
/footwear,/footwear/
/sale,/offers
/offers,/sale
```

it prints `/2019/shoes,/footwear/,3` and `/shoes,/footwear/,2`, and reports `LOOP: /sale -> /offers -> /sale` instead of writing it out. Fix the loop by hand; the script can't know which page should win.

The map only knows about its own rules. Hops added by the server (scheme, host, trailing slash) aren't in it, so run `chains.sh` over the flattened targets afterwards. Every target should answer `200` with zero redirects. If a target shows one redirect, write the final form (usually with or without the trailing slash) into the map.

## Make the server do it in one hop

Flattening the map isn't enough if the server still runs normalisation and the content redirect as separate steps. Any variant of an old URL (`http`, apex, trailing slash or not) should reach the final URL in one response.

### nginx: one map, checked before normalisation

Put the redirect map in an nginx [`map`](https://nginx.org/en/docs/http/ngx_http_map_module.html) and check it in every server block, including the `http` and apex ones, before the generic host redirect:

```
# http {} context
map $uri $redirect_to {
    default "";
    include /etc/nginx/redirects.map;   # lines like: /2019/shoes /footwear/;
}

server {
    listen 80;
    listen 443 ssl;
    server_name example.com www.example.com;
    # ssl_certificate ... (both names)

    # old URL: straight to the final canonical URL, whatever host or scheme was asked for
    if ($redirect_to) {
        return 301 https://www.example.com$redirect_to;
    }
    # everything else on the wrong host or scheme: one hop to the canonical host
    if ($host != "www.example.com") {
        return 301 https://www.example.com$request_uri;
    }
    if ($scheme = http) {
        return 301 https://www.example.com$request_uri;
    }
    # ... normal site config
}
```

Two details from the nginx docs: `map` "Strings are matched ignoring the case", so `/Shoes` and `/shoes` share an entry; use a regex entry with `~` if you need case-sensitive matching. And `rewrite` with the `redirect` flag answers `302` ([rewrite module](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html)), so prefer `return 301` for permanent moves.

Generate `redirects.map` from `flattened.csv` instead of editing it by hand, and the map can't drift back into chains.

### Apache: one rule for host and scheme

Apache sites often have three separate `RewriteRule` blocks copied from three tutorials. Merge them. With `[R=301,L]` the [flag docs](https://httpd.apache.org/docs/2.4/rewrite/flags.html) give you an external redirect (a bare `[R]` is a `302`):

```
RewriteEngine On

# content redirects first, already pointing at the final https://www URL
RewriteRule ^2019/shoes/?$ https://www.example.com/footwear/ [R=301,L]
RewriteRule ^shoes/?$      https://www.example.com/footwear/ [R=301,L]

# then host + scheme in one hop
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} !^www\.example\.com$ [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [R=301,L]
```

Behind a load balancer or CDN that terminates TLS, `%{HTTPS}` is always `off` at the origin and the last rule loops. Check the forwarded protocol header your proxy sets instead.

### Next.js: absolute destinations and flattened lists

Next.js `redirects` in `next.config.js` run inside the app, after any CDN rules. Two habits keep them from adding hops:

- Write destinations in their final form, including your `trailingSlash` setting. With the default (no trailing slash), a destination of `/footwear/` triggers a second redirect to `/footwear`.
- Generate the list from the flattened CSV at build time instead of appending rules by hand.

```javascript
// next.config.js
const fs = require("fs");

const rows = fs.readFileSync("./redirects/flattened.csv", "utf8").trim().split("\n").slice(1);

module.exports = {
  async redirects() {
    return rows.map((line) => {
      const [source, destination] = line.split(",");
      return { source, destination, permanent: true }; // 308
    });
  },
};
```

The Next.js [redirecting guide](https://nextjs.org/docs/app/guides/redirecting) notes that `redirects` "may have a limit on platforms. For example, on Vercel, there's a limit of 1,024 redirects", and suggests handling larger maps in Proxy with a lookup. The same rule applies there: look up the final target and answer once.

## Update the things that point at redirects

A flat redirect map still costs one hop for every request that uses it. Most of those requests come from your own site, and those you control:

- **Internal links.** Replace links to old URLs with the final URL. A crawl that records the link source makes this a find-and-replace per template. See broken internal links for the template-level approach, which works the same for links to redirects.
- **XML sitemaps.** List only final `200` URLs. A sitemap full of redirects is the most common reason for a long **Page with redirect** list.
- **Canonical and hreflang tags.** A canonical pointing at a URL that redirects asks Google to trust a URL that itself says "go elsewhere".
- **Your own campaigns and emails**, where the links are often hard-coded years ago.

Leave the redirects themselves in place. The site move guide says to "Keep the redirects for as long as possible, generally at least 1 year", so Google can recrawl and reassign links from other sites. Backlinks and bookmarks still need them after that; cleaning up is about removing the hops in the middle, not the redirect at the start.

## How SEOFix reports chains

SEOFix doesn't follow redirects automatically: each hop is recorded and fetched as its own URL ([how the crawler works](https://seofix.ai/help/how-the-crawler-works)). That way a full audit sees every chain among the URLs it crawls. [`REDIRECT_CHAIN`](https://seofix.ai/help/checks-links-and-redirects) fires when a URL takes two or more redirects to reach a URL that doesn't redirect (it follows up to 10 hops) and lists the whole `chain`. `REDIRECT_LOOP` catches the loops, and `REDIRECTED_INTERNAL_LINK` lists every page that links to a redirect, with the final URL to link to instead. When we audited our own 37,000-page job board, it found no chains at all, but 566 internal links that went through a redirect.

## FAQ

### How many redirects in a chain is too many?

Google follows up to 10, and its site move guide asks for "ideally no more than 3 and fewer than 5" when you can't avoid a chain. Aim for one hop from any old URL, and zero hops from your own links.

### Do redirect chains lose PageRank?

Google doesn't publish a per-hop loss. What it does document is that only the final URL's content is processed, that temporary hops are a weak canonical signal, and that long chains hurt crawling. That's enough reason to flatten them.

### Does Search Console show redirect chains?

Not directly. A chain that ends in a working page usually shows up as **Page with redirect** for the first URL. A chain over the limit shows as **Redirect error**. Google's inspection tools don't follow redirects, so a live test of the old URL reports the redirect itself, not the page at the end.

### Should I remove old redirects once the chain is fixed?

No. Remove the intermediate hops by pointing each source at the final URL. Keep the redirect from each old URL; backlinks and bookmarks still use it.

---

SEOFix crawls your site hop by hop and lists every redirect chain, loop and internal link to a redirect, grouped by the template that produces them. Your first audit is free (up to 500 pages, no card).
