Where to Put a Redirect: Edge, Server, or Application

A 301 is a 301 regardless of what emits it. What changes with the layer is everything around it: how fast it responds, who can change it, whether it is visible in code review, and whether a rule further down the stack can be silently shadowed by one above.

The layers, from outermost in: DNS-level services, CDN or edge rules, the reverse proxy or web server, and the application. A request passes through them in that order and stops at the first one that answers.

The ordering is the whole model

Whichever layer responds first wins. That sounds obvious and produces the most confusing category of redirect bug, because a correct rule in the application is invisible when an edge rule intercepts the same path.

The diagnostic consequence: when a redirect does not behave as configured, the question is not “is my rule right” but “which layer is answering.” A rule that looks correct in the file you are reading may not be the rule being executed. This is why a redirect audit starts with a request rather than with the config.

Edge and CDN rules

Latency: lowest available. The response comes from a point of presence near the client and never touches your origin.

Deploy coupling: none. Rules change through a dashboard or a provider API, independent of your application release cycle.

Visibility: poor, and this is the significant cost. Edge rules do not appear in your repository, are not covered by code review, and are not visible to a developer reading the application. A redirect that nobody can find is a redirect that survives three redesigns.

Best for: protocol and host normalisation, which is a property of the whole site and should be resolved before anything else runs — see consolidating www and HTTPS in a single hop. Also good for emergency redirects during an incident, and for large static maps imported from a file.

Watch for: rule-count limits on the plan you are on, and pattern-matching syntax that differs meaningfully between providers. And cached redirect responses — if the edge caches a 301 and you change the rule, the old response may keep being served until it expires or is purged. That is the single most common “I fixed it and it did not change” report at this layer.

Web server and reverse proxy

Latency: one round trip to the origin, no application code executed.

Deploy coupling: the server’s own config-management process. Usually version-controlled, usually reviewed, often a different pipeline from application deploys.

Visibility: good if the config is in a repository. Poor if the file is edited on the box, which happens.

Best for: bulk static maps. A migration’s redirect map is thousands of one-to-one mappings, and this is the right layer for it — the server handles them efficiently, they are expressible as data rather than code, and they can be generated by a script and reviewed as a diff.

nginx, using a map for a large static set:

# nginx: a redirect map loaded from data
map $request_uri $redirect_target {
    default                      "";
    /old/widgets                 /products/widgets/;
    /old/gadgets                 /products/gadgets/;
    include /etc/nginx/redirects.map;
}

server {
    if ($redirect_target != "") {
        return 301 $redirect_target;
    }
}

The include keeps the generated portion separate from hand-written entries, which is what makes the map regenerable without a merge conflict every time. Note that $request_uri includes the query string, so entries must match exactly — for path-only matching, use $uri and re-append $is_args$args to the target.

Watch for: .htaccess on Apache is per-directory, evaluated on every request, and rules in a subdirectory can override the parent in ways that are hard to follow. And nginx’s if inside location has documented surprising behaviour with certain directives; prefer return and rewrite at server level where possible.

Application

Latency: highest. Framework boot, routing, possibly a database query, before a response with no body is returned.

Deploy coupling: full. A redirect change requires a release.

Visibility: best. It is in the codebase, in review, testable in the test suite.

Best for: redirects that need logic. Redirecting based on the authenticated user’s state, on a lookup of a renamed slug, on a locale negotiation. Anything where the destination is computed rather than looked up in a static table.

The slug-history pattern is the canonical example and worth naming, because it removes a whole class of manual redirect:

# resolve a post by slug, redirecting historical slugs to the current one
post = Post.find_by_slug(slug)
if post is None:
    post = Post.find_by_historical_slug(slug)
    if post:
        return redirect(post.url, code=301)
    abort(404)

Every rename records the old slug, and the redirect exists automatically and forever. Compare that with the alternative — someone remembering to add a rule to a config file every time an editor changes a title.

Watch for: the cost of booting the framework to serve a bodyless response. On a high-volume path, that is real. Static redirects belong further out.

DNS-level redirects

Registrars and DNS providers offer “URL forwarding” or “web forwarding.” Mechanically these are not DNS at all — DNS resolves names to addresses and cannot express HTTP semantics. What happens is that the provider points the name at their own HTTP server, which issues the redirect.

Useful for: parked domains and domain-level forwarding where you do not want to run infrastructure.

Limitations that matter: frequently a 302 rather than a 301, with no option to choose; often no path preservation, so every URL on the old domain lands on the new domain’s homepage — which fails the equivalence test and reads as a soft 404; and sometimes implemented as an HTML frame rather than an HTTP redirect, which is not a redirect at all and should never be used for anything you care about.

If a domain has links worth preserving, do not use registrar forwarding. Point the domain at infrastructure you control and serve real 301s with path preservation. This is precisely the situation in preserving links through a site migration.

Meta refresh and JavaScript

Both are redirects that happen after the response, in the client. Their handling and trade-offs are enough of a separate topic to have their own post — meta refresh and JavaScript redirects — but the short version for layer selection: neither is a substitute for an HTTP redirect, and if you can emit a status code, do.

Choosing

A rule of thumb that holds up:

  • Site-wide normalisation (protocol, host, slash, case) → edge, in one combined rule.
  • Static one-to-one maps from a migration → web server, generated from data, version-controlled.
  • Computed or stateful redirects → application.
  • Whole-domain moves → whatever layer can preserve the path, which usually means not the registrar.

And a discipline that matters more than the choice: keep an inventory. One document or one file that says which layer holds which class of redirect. Without it, every future debugging session starts by discovering the architecture from scratch, and the layer nobody remembers is the one that shadows the fix.

Finding out which layer answered

Most edges add identifying response headers, and comparing an external request with one made against the origin directly localises the rule:

# what the world sees
curl -sI https://example.com/old-path | grep -iE '^(HTTP|location|server|cf-|x-cache)'

# what the origin serves, bypassing the edge
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/old-path \
  | grep -iE '^(HTTP|location)'

If the first redirects and the second does not, the rule is at the edge. If both redirect but to different targets, you have two rules and the outer one is winning — which is the finding that explains most of these investigations. Logs at each layer confirm which one fired.