A followed redirect makes the client resolve a Location value and issue another request before it reaches the desired representation. Several individually reasonable rules can form a chain: normalize a hostname, add HTTPS, fix a trailing slash, and move an old article. Review the complete path seen by a visitor, because each intermediary becomes another dependency.

Requests and connections are different counts

A fresh redirect response normally adds a sequential request-response exchange. The next destination is learned from that response, so useful work waits for the earlier step. However, another request does not necessarily require another TCP connection. Same-origin HTTP/1.1 can reuse a persistent connection, while HTTP/2 and HTTP/3 have their own connection and stream behavior. Moving to another host can add name resolution or connection establishment, depending on what is already available.

Cached redirects and browser transport upgrades can alter the network sequence. HSTS may upgrade an HTTP URL internally, without contacting its plaintext endpoint. A permanent redirect may already be cached. Consequently, neither a fixed number of milliseconds per hop nor a claim that three redirects triple total page load time is defensible without measurement. Redirect traversal is only part of loading a document and its subresources.

Choose status codes for their meaning. With 301 and 302, historical handling permits a POST to become GET; 307 and 308 preserve the method, while 303 directs retrieval of another resource. Permanence and cacheability also matter. Replacing every rule with a permanent redirect merely to improve a trace can change client behavior long after a mistaken release is corrected.

Location can contain a relative reference. Resolve it against the current request URL before deciding its origin and destination. Recording only the raw header can hide how an apparently identical value leads to different paths when returned from different directories.

When it breaks

Two rules can send a request back and forth, such as opposite preferences for a trailing slash. A successful response at each individual endpoint does not prove termination. Clients enforce redirect limits, but reaching that limit is a failure rather than successful protection of the user’s task. Trace visited destinations and test a representative set of old URLs after changing shared rules.

A middle destination can also become unavailable while the final page remains healthy. Update internal links to the final canonical destination and, where semantics allow, point historical addresses there directly. Preserve meaningful query parameters and encoding during this change. Avoid unrestricted user-supplied redirect targets, and do not blindly forward credentials to a different origin.

Crawler traversal consumes requests too, but a chain does not imply a predictable search ranking penalty. Search engines have their own processing and limits. Report observable outcomes such as hop counts, loops, final status, and target consistency; do not turn a shorter local trace into an unsupported SEO claim.

Check it quickly

Save the following as redirect-chain.py and run python3 redirect-chain.py. Python 3 creates an HTTP/1.1 loopback server and a single HTTPConnection. Responses contain explicit lengths so the client can finish reading each response and reuse its connection. Cache-Control is no-store to keep the experiment about actual exchanges rather than cached redirects.

from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.parse import urljoin, urlsplit
import json

events = []
routes = {'/long': '/middle', '/middle': '/final', '/short': '/final',
          '/loop-a': '/loop-b', '/loop-b': '/loop-a'}


class Handler(BaseHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'

    def do_GET(self):
        events.append({'path': self.path, 'peer_port': self.client_address[1]})
        status = 302 if self.path in routes else 200 if self.path == '/final' else 404
        data = b'final representation' if status == 200 else b''
        self.send_response(status)
        if status == 302:
            self.send_header('Location', routes[self.path])
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
try:
    origin = f'http://127.0.0.1:{server.server_port}'

    def follow(start):
        path, seen, trail = start, set(), []
        for _ in range(5):
            if path in seen:
                raise ValueError('redirect loop')
            seen.add(path)
            connection.request('GET', path)
            response = connection.getresponse()
            body = response.read()
            trail.append([path, response.status])
            if response.status == 200:
                assert body == b'final representation'
                return trail
            assert response.status in (301, 302, 303, 307, 308)
            target = urlsplit(urljoin(origin + path, response.getheader('Location')))
            assert (target.scheme, target.netloc) == ('http', urlsplit(origin).netloc)
            path = target.path + ('?' + target.query if target.query else '')
        raise ValueError('redirect budget exhausted')

    long_path = follow('/long')
    short_path = follow('/short')
    assert len(long_path) == 3 and len(short_path) == 2
    try:
        follow('/loop-a')
    except ValueError as error:
        assert str(error) == 'redirect loop'
    else:
        raise AssertionError('loop accepted')
    connections = len({event['peer_port'] for event in events})
    assert connections == 1 and len(events) == 7
    print(json.dumps({'long_path': long_path, 'short_path': short_path,
                      'loop_rejected': True, 'requests': len(events),
                      'observed_tcp_connections': connections}))
finally:
    connection.close()
    server.shutdown()
    thread.join()
    server.server_close()

Expect /long → /middle → /final to contain three requests and /short → /final two. The loop begins at /loop-a, visits /loop-b, and is rejected before requesting the first destination again. The server observes seven requests in total and exactly one peer connection. These assertions ran successfully with Python 3.14.4; the program then closes its connection and server.

This is a count experiment using GET and temporary 302 responses. It does not benchmark latency, exercise POST rewriting, or test TLS and cross-origin credential behavior. The client intentionally accepts only same-origin targets and a small hop budget; it is not a general redirect library. In a real deployment, combine a full request trace with tests for method, query, host, and old-address preservation. A direct route is useful only if it still reaches the intended representation with the required semantics.

Sources