HTTP 301 vs 302 for URL shorteners
A standard follow-up on the Bitly problem, and a good one, because it pits server load and latency directly against analytics accuracy and operational control. There is a correct answer, but only if you say what it costs.
1. What each status code means to a browser
301 Moved Permanently — "This mapping will never change. Cache it and don't ask again." Browsers cache 301s on disk, often indefinitely and often ignoring Cache-Control. Chrome and Firefox both historically cached 301s aggressively across sessions.
302 Found (and its stricter sibling 307 Temporary Redirect) — "Go here now, but ask me again next time." Not cached by default; every visit hits your server.
The difference in behaviour is bigger than the difference in semantics suggests, because a cached 301 removes you from the loop permanently, on that device.
2. The trade-off
| Attribute | 301 Moved Permanently | 302 Found |
|---|---|---|
| Browser caching | Aggressive, often permanent | None by default |
| Repeat-click server load | Near zero | Every click hits you |
| Repeat-click latency | ~0ms (resolved locally) | One network round trip |
| Analytics completeness | Only the first click per device | Every click |
| Revocation / re-targeting | Effectively impossible on cached devices | Immediate |
| SEO link equity | Passed to the destination | Historically not (now largely equivalent, but 301 remains the signal) |
The two rows in bold are what decide it.
Why 301 breaks the product
Analytics. A shortener's value proposition is measurement: click counts, geography, referrer, device, time. With a 301, the browser stops telling you about the second and every subsequent click from that device. Your numbers do not merely become approximate — they become systematically biased toward first-time visitors, which is the opposite of what a campaign report needs.
Revocation. This is the more serious one. Shorteners are phishing infrastructure by design: they hide the destination. When Trust & Safety flags a link as malware, it must stop redirecting everywhere, within seconds. With a 301 cached on a million devices, you cannot recall it. The only remedy is asking users to clear their browser cache, which is not a remedy.
The same applies to legitimate edits: a user who re-targets a printed QR code cannot, if the original response was a 301.
3. The answer: 302 with a short private cache
HTTP/1.1 302 Found
Location: https://example.com/destination
Cache-Control: private, max-age=60
This is the production answer, and each token is doing work:
302keeps the browser asking, so analytics stay complete and revocation stays possible.privatestops shared caches (corporate proxies, ISP caches) from serving the redirect to other users — which would both corrupt analytics and, more importantly, leak one user's resolution to another.max-age=60absorbs the pathological case: a user who clicks the same viral link ten times in a minute generates one request, not ten. You bound your worst-case staleness at 60 seconds, which is an acceptable revocation window.
Sixty seconds of browser caching removes the large majority of duplicate requests while capping the damage from a malicious link at one minute per device.
4. Terminating at the edge
The other half of the answer is where the 302 is generated. A round trip to an origin in us-east-1 costs a user in Jakarta 200ms+ before your code runs. Terminating the redirect at a CDN edge PoP removes that entirely:
// CloudFront Function (viewer-request). Runs in a V8 isolate at the PoP in <1ms.
import cf from 'cloudfront';
const kvs = cf.kvs();
async function handler(event) {
const code = event.request.uri.slice(1);
if (!code) return event.request; // fall through to origin
try {
const target = await kvs.get(code); // in-process memory read
return {
statusCode: 302,
statusDescription: 'Found',
headers: {
'location': { value: target },
'cache-control': { value: 'private, max-age=60' },
},
};
} catch (e) {
return event.request; // MISS -> forward to origin
}
}
Two mechanics worth knowing precisely, because interviewers probe them:
Returning request versus returning a response. Returning a response object terminates at the edge. Returning the unmodified request tells CloudFront to continue down the normal pipeline to the origin. That single distinction is how you express "hit" and "miss."
Edge functions cannot make outbound network calls. They cannot write to Kafka, call a database, or fetch anything. So the analytics event cannot be emitted from the function. Instead, use CloudFront Real-Time Logs, which stream request metadata out-of-band into Kinesis within seconds, adding exactly zero latency to the user's request. This constraint is a gift: it forces the analytics path to be asynchronous, which is what you wanted anyway.
The edge store has hard limits — see CloudFront KeyValueStore specifics — so it holds the hot set, not the corpus.
5. Making the origin miss cheap too
When a code is not in the edge store, the request reaches your origin. Do not let the next request for that code pay the same cost:
HTTP/1.1 302 Found
Location: https://example.com/destination
Cache-Control: public, max-age=300
Note public here, not private. This lets CloudFront's own HTTP cache store the redirect for five minutes, so subsequent requests for that code are served from the edge without touching your origin — even though the code was never promoted into the edge KV store.
The full escalation looks like this:
Cold link -> origin resolves from Redis/DynamoDB, returns 302 public max-age=300
Warming link -> served from CloudFront's standard HTTP cache for 5 minutes
Hot link -> promoted into edge KV by a background worker watching the click
stream; now resolved in <1ms at the PoP with no origin involvement
The promotion loop is worth describing because it shows the system adapting: a worker consuming the click stream detects a code exceeding, say, ten clicks per minute and writes it to the edge store, which propagates to all PoPs in a few seconds.
6. When 301 is right
Be able to name the cases, because a candidate who only knows "always 302" has memorised rather than understood:
- A permanent domain migration.
oldcompany.com/*→newcompany.com/*. There is no analytics requirement, no revocation scenario, and you actively want browsers to stop asking. - Canonicalisation.
http→https,www→ apex. Permanent by definition. - A link explicitly marked immutable by the product — some shorteners offer a "permanent, no-analytics" link type precisely to get the latency and cost benefits.
7. What to say in an interview
"302 with
Cache-Control: private, max-age=60. The product is analytics and the ability to revoke a malicious link, and a 301 forfeits both — browsers cache 301s indefinitely, so you lose every repeat click from your numbers and you can't recall a phishing link once it's out. The 60-second private cache still absorbs the pathological repeat-click case while capping revocation lag at a minute.Then I'd terminate the 302 at the CDN edge rather than the origin: a CloudFront Function reading an edge KV store resolves the hot set in under a millisecond, and because edge functions can't make outbound calls, analytics go out-of-band through CloudFront Real-Time Logs into Kinesis — which is the right shape anyway, since counting must never be in the redirect's critical path. Origin misses respond with
public, max-age=300so the CDN's own cache absorbs the next five minutes, and a background worker promotes hot codes into the edge store."
Follow-ups to expect:
- "What about 307/308?" → 307 and 308 are the strict versions of 302 and 301: they forbid changing the request method on redirect. For a
GET-only shortener the distinction rarely matters, but 307 is the more correct temporary redirect. - "Why
private?" → a shared proxy caching the redirect would serve it to other users, corrupting per-user analytics and leaking one user's resolution. - "How fast can you actually revoke?" → bounded by the longest cache in the chain: 60s browser + edge KV propagation (~5s) + a CDN invalidation. Enumerate every layer and its bound; that is the real answer.