A redirect looks simple: the server answers with a status code and a Location header, and the client goes there. But the three digits decide whether browsers may cache the redirect, whether a POST stays a POST, and which URL search engines are likely to index.
This guide covers the 3xx class as defined in RFC 9110, the history behind its overlaps, redirects without a status code, and how to configure the right code on common servers.
What the 3xx class means
RFC 9110, the current specification of HTTP semantics, says that a 3xx status code indicates that "further action needs to be taken by the user agent in order to fulfill the request". Section 15.4 groups the codes into four kinds of redirection:
- The resource is available at another URI, given in
Location: 301, 302, 307 and 308. - The client can choose among several representations of the resource: 300.
- A different resource provides an indirect response to the request: 303.
- The client can reuse a copy it already has in its cache: 304.
In practice, only 301, 302, 303, 307 and 308 work as automatic redirects. The Fetch Standard, which browsers implement, calls exactly these five codes "redirect statuses". A 300 is usually displayed like a normal page, and a 304 tells the client to use the copy in its cache.
The Location value may be a relative reference such as /new-page or ../shop/. The client resolves it against the URL it just requested (RFC 9110, Section 10.2.2). If Location has no fragment, the original one is kept: a link to /guide#install that redirects to /docs/guide ends at /docs/guide#install.
For the five real redirects, two questions decide which code fits:
- Is the move permanent or temporary? Permanent redirects may be cached, and they signal search engines to index the new URL instead.
- Must the request method be kept? This matters when the original request is a POST, PUT or DELETE rather than a plain GET.
Redirect status codes at a glance
| Code | Name | Permanent | Method kept | Cacheable by default | Typical use | Google Search |
|---|---|---|---|---|---|---|
| 300 | Multiple Choices | – | – | Yes | Offering alternative representations (rare) | – |
| 301 | Moved Permanently | Yes | No: POST may become GET | Yes | Moved pages and sites, HTTPS and host canonicalization | Permanent (strong signal) |
| 302 | Found | No | No: POST may become GET | No | Short-term redirects, login walls, A/B tests | Temporary (weak signal) |
| 303 | See Other | No | No: becomes GET | No | Result page after a form submission | Temporary |
| 304 | Not Modified | Not a redirect | – | – | Answer to a conditional request | Content unchanged |
| 305 | Use Proxy | Deprecated | – | – | Do not use | – |
| 306 | (Unused) | Reserved | – | – | Do not use | – |
| 307 | Temporary Redirect | No | Yes | No | Temporary redirects of any method | Temporary |
| 308 | Permanent Redirect | Yes | Yes | Yes | Permanent moves of APIs, forms or any URL | Permanent (strong signal) |
"Cacheable by default" means the code is heuristically cacheable under RFC 9110, Section 15.1: a cache may reuse the response without checking with the server, even when it carries no explicit freshness information. Any response can still be made cacheable or uncacheable with Cache-Control. The Google column summarizes Google's documentation on redirects and HTTP status codes.
The codes one by one
300 Multiple Choices
The resource has several representations, such as different languages or file formats, and the client may choose. The body usually lists the options, in no standard format. A preferred choice can be named in Location, but you cannot rely on browsers to follow it. In practice 300 is almost never used: server-side content negotiation or an ordinary page with links works better.
301 Moved Permanently
The target resource "has been assigned a new permanent URI and any future references to this resource ought to use one of the enclosed URIs". This is the standard code for moved pages, changed domains, HTTP-to-HTTPS upgrades and canonical host redirects.
Two details matter. A 301 is heuristically cacheable, so browsers may remember it and stop asking the server (see the caching section below). And for historical reasons, a client may change a POST into a GET when following a 301, which browsers do. For pages loaded with GET this is irrelevant; for a form endpoint or an API, use 308.
302 Found
The target resource "resides temporarily under a different URI", so clients should keep using the original URL for future requests. A 302 is not cacheable by default. Use it for short-lived situations: a sale page that replaces a category for a week, a login wall, geographic routing or an A/B test.
Like a 301, a 302 allows the change from POST to GET. Many frameworks send 302 when no code is given, which is why permanent moves so often end up as 302s by accident.
303 See Other
A 303 sends the client to a different resource that serves as an indirect response to the original request, and the client retrieves it with GET (or HEAD). It is designed for the Post/Redirect/Get pattern: after processing a form submission, the server answers 303 with the URL of a confirmation or result page. Reloading that page then repeats a harmless GET instead of resubmitting the form.
A 303 is not cacheable by default, and it does not claim that the original URL has moved. Google treats it like a 302.
304 Not Modified (not a redirect)
Despite its 3xx number, a 304 never sends the client anywhere. It answers a conditional GET or HEAD request: one that carries If-None-Match with an ETag value, or If-Modified-Since with a date. If the resource has not changed, the server replies 304 without a body, and the client uses the copy in its cache.
GET /styles.css HTTP/1.1
Host: example.com
If-None-Match: "5f1c-3a2b"
HTTP/1.1 304 Not Modified
ETag: "5f1c-3a2b"
Cache-Control: max-age=3600Redirect checkers send unconditional requests, so they normally never see a 304. To test one in TrueURL, run a normal check, copy the ETag value from its response headers, and send it as an If-None-Match header under Custom Request Headers. For Googlebot, a 304 means the content is unchanged since the last crawl.
305 Use Proxy and 306 (Unused)
A 305 told the client to repeat the request through the proxy named in Location. Letting a response reconfigure a client's proxy is a security risk, so RFC 7231 deprecated the code, and current browsers do not act on it.
306 was defined in a previous version of the specification (it is often listed as Switch Proxy) and is now reserved. Neither code belongs in a modern application; if you see one, something is misconfigured.
307 Temporary Redirect
A 307 means the same as a 302, with one addition: the client must not change the request method. A POST is resent as a POST, with the same body, to the new URL. That makes 307 the right temporary redirect for APIs and form endpoints.
You may also meet 307 in Chrome DevTools as a 307 Internal Redirect from http:// to https://. The server never sent it. The browser upgraded the request by itself, usually because of an HSTS policy it has stored for the site.
308 Permanent Redirect
A 308 is the method-preserving counterpart of 301: permanent, heuristically cacheable, and the client must not change the method. It was first published in 2014 in the experimental RFC 7238, became a standard with RFC 7538 in 2015 and is now part of RFC 9110.
Use 308 for permanent moves of endpoints that receive POST, PUT or DELETE requests. For ordinary pages, 301 and 308 are interchangeable, and Google treats both as permanent redirects.
Why 303, 307 and 308 exist
The overlap between 301/302 and 303/307/308 is a historical accident.
HTTP/1.0 (RFC 1945, 1996) defined 301 Moved Permanently and 302 Moved Temporarily. It said a user agent must not redirect a POST automatically without the user's confirmation, and already noted that some user agents wrongly turned the POST into a GET. Browsers kept doing so.
HTTP/1.1 added two codes that leave no room for interpretation. RFC 2068 (1997) introduced 303 See Other, which always switches to GET. RFC 2616 (1999) added 307 Temporary Redirect, which always keeps the method, renamed 302 to Found, and openly noted that most user agents treated a 302 like a 303.
That still left no permanent redirect that keeps the method. 308 filled the gap fifteen years later. Meanwhile the specification accepted reality: since RFC 7231 (2014), a user agent may change a POST into a GET after a 301 or a 302.
Today, browsers apply these rules from the Fetch Standard:
| Status | Original method | Browsers request the new URL with |
|---|---|---|
| 301, 302 | POST | GET, without the body |
| 301, 302 | Any other method | The same method |
| 303 | Any method except GET and HEAD | GET, without the body |
| 303 | GET or HEAD | The same method |
| 307, 308 | Any method | The same method and body |
Only POST is rewritten after a 301 or 302; a PUT or DELETE keeps its method. Relying on that is fragile, because HTTP libraries differ, so use 307 or 308 whenever the method matters.
Redirects without a 3xx status code
Meta refresh
<meta http-equiv="refresh" content="0; url=https://example.com/new-page">The page itself is delivered with 200 OK, and the browser follows the tag after loading it. The number is the delay in seconds. Google interprets an instant meta refresh as a permanent redirect and a delayed one as a temporary redirect.
Timed refreshes also hurt accessibility. WCAG accepts an instant meta refresh as a client-side redirect (technique H76), but treats a timed one as a failure (F40), because the page changes on a timer that users cannot control.
JavaScript redirects
<script>
window.location.replace('https://example.com/new-page');
</script>location.replace() does not leave the redirecting page in the session history, so the Back button does not bounce users into the redirect again. Assigning location.href does leave it there.
Google runs JavaScript only when it renders a page, after crawling it, and rendering can fail. Its documentation recommends JavaScript redirects only when server-side and meta refresh redirects are not possible. Many other bots and tools never run JavaScript at all.
The Refresh header
HTTP/1.1 200 OK
Content-Type: text/html
Refresh: 0; url=https://example.com/new-pageThe Refresh header is not defined in any HTTP RFC. Browsers have supported it for decades, and the HTML Standard treats it as equivalent to a meta refresh tag. Many HTTP clients and tools, curl among them, ignore it, so prefer a real 3xx redirect.
How to implement redirects correctly
nginx
# Every http:// request, on either host, goes to the canonical HTTPS URL in one hop
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
# https://www.example.com -> https://example.com
server {
listen 443 ssl;
server_name www.example.com;
ssl_certificate /etc/ssl/example.com.crt;
ssl_certificate_key /etc/ssl/example.com.key;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
# ssl_certificate and ssl_certificate_key as above
# One moved page
location = /old-page {
return 301 /new-page;
}
# A whole section, keeping the rest of the path
location /blog/ {
rewrite ^/blog/(.*)$ /articles/$1 permanent;
}
}$request_uri contains the original path and query string, so nothing is lost. rewrite ... permanent sends a 301 and rewrite ... redirect a 302; for 307 or 308, use return with that code.
Apache (.htaccess)
# mod_alias: one page, and a whole folder (the rest of the path is appended)
Redirect 301 /old-page https://example.com/new-page
Redirect 301 /blog/ https://example.com/articles/
# mod_alias with a regular expression
RedirectMatch 301 ^/products/([0-9]+)\.html$ https://example.com/p/$1
# mod_rewrite: send every http:// and www request to https://example.com in one hop
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]Redirect without a status code sends a 302, so always write the code. Behind a load balancer or CDN that connects to Apache over plain HTTP, %{HTTPS} is always off and the last rule loops; test %{HTTP:X-Forwarded-Proto} instead. mod_alias and mod_rewrite work independently, and the order of the lines in the file does not decide which module runs first. A URL that matches rules in both, such as http://www.example.com/old-page here, can take two hops; for URLs that must redirect in one hop, keep all their rules in one module.
Cloudflare
Cloudflare can redirect at the edge, before a request reaches your server:
Single Redirects (the Redirect Rules section of the dashboard) match requests with a filter expression and send them to a static or dynamic target with 301, 302, 303, 307 or 308. A typical www-to-apex rule has these settings:
When incoming requests match: (http.host eq "www.example.com") URL redirect type: Dynamic Expression: concat("https://example.com", http.request.uri.path) Status code: 301 Preserve query string: OnBulk Redirects handle long lists of source and target URLs, such as a migration map uploaded as CSV. Each entry has its own status code and options such as preserving the query string, and a Bulk Redirect Rule activates the list.
Workers can build redirects in code with
Response.redirect():
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/old-page') {
url.pathname = '/new-page'; // the query string is kept
return Response.redirect(url.toString(), 301);
}
return fetch(request);
},
};Response.redirect() defaults to 302 and accepts only 301, 302, 303, 307 and 308. Any other code throws a RangeError.
Node.js and Express
import express from 'express';
const app = express();
app.set('trust proxy', 1); // when running behind a load balancer or CDN
// HTTPS and the canonical host in one hop
app.use((req, res, next) => {
if (req.secure && req.hostname === 'example.com') return next();
res.redirect(301, `https://example.com${req.originalUrl}`);
});
// One moved page
app.get('/old-page', (req, res) => {
res.redirect(301, '/new-page');
});res.redirect(url) without a status sends a 302. Express 5 supports only the res.redirect(status, url) order. In plain Node.js, set the status and header yourself:
res.writeHead(301, { Location: 'https://example.com/new-page' });
res.end();PHP
<?php
// Must run before any output is sent.
header('Location: https://example.com/new-page', true, 301);
exit;A Location header on its own makes PHP send a 302 (unless a 201 or 3xx status was already set), so pass the code as the third argument, and exit so nothing else runs. In WordPress, wp_redirect() and wp_safe_redirect() also default to 302; pass 301 as the second argument.
Choosing the right redirect code
- The page has moved for good and it is an ordinary page loaded with GET: use 301. A 308 works just as well.
- The move is permanent and clients may send POST, PUT or DELETE, as with APIs, form actions and webhooks: use 308.
- The redirect is temporary and only GET matters: use 302.
- The redirect is temporary and the method must be kept: use 307.
- A form or other POST has been processed and the user should see a result page: use 303.
- HTTP to HTTPS: use 301 or 308, and send a
Strict-Transport-Securityheader from the HTTPS site so that browsers skip the insecure request next time. - The content is gone and has no equivalent: do not redirect. Return 404 or 410. Google may treat a redirect to an unrelated page, such as the home page, as a soft 404.
- 300, 305 and 306: do not use them. A 304 is produced by your server or cache for conditional requests and is never a redirect choice.
The caching pitfall of 301 and 308
Because 301 and 308 are heuristically cacheable, browsers may store them and stop asking the server. Chromium-based browsers keep a 301 or 308 without caching headers until the entry is evicted or the user clears the cache. A wrong permanent redirect can keep sending past visitors to the wrong place long after you fix the server, and you cannot reliably recall it from their browsers.
To stay safe:
- Test new rules with 302 or 307 first, and switch to 301 or 308 once the destination is confirmed.
- Alternatively, limit how long the permanent redirect may be cached while you roll it out:
location = /old-page {
add_header Cache-Control "max-age=3600";
return 301 https://example.com/new-page;
}- When testing, use a private window or DevTools with Disable cache, or a server-side checker, which never uses a browser cache.
- Purge CDN and proxy caches after changing a redirect, because they may store redirects too.
- Do not try to undo a cached A → B redirect by redirecting B back to A. Visitors whose browsers cached the first redirect can end up in a loop.
Testing your redirects
Whatever your platform, verify the result from the outside. The 301 Redirect Checker confirms that every hop of a permanent move is a 301 or 308, the Redirect Chain Checker shows each hop with its Location and headers, and the HTTPS Redirect Checker tests all four protocol and host variants at once. The Meta Refresh Checker finds client-side redirects that header-only tools miss, and the Redirect Latency Checker measures what each hop costs.
From a terminal, curl -sIL https://example.com/old-page prints the headers of every hop. Our guide on how to check URL redirects covers the command line, DevTools and common redirect problems in detail.