HTTP Codes — complete guide for programmers: all statuses, applications and security pitfalls
Most HTTP status code documents are lists of 63 items with one-line definitions. Little comes from that. This document goes the opposite way — seven real scenarios that you write in the backend, each with complete working Fastify + Prisma code, shown in two versions: with security bugs and without. HTTP status codes appear where they always appear — in context. At the end a reference table of all 63 codes with RFC assignments — when you need to search by number.
1. REST API — CRUD for posts
We start with the most common case. We write an endpoint managing blog posts. Naturally, eight HTTP codes will appear in it: 200, 201, 204, 304, 400, 404, 409, 422. Each in its own place, not because "we had to use it", but because it solves a concrete problem.
Full endpoint — Fastify + Prisma
// apps/api/src/routes/posts.ts
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import crypto from 'node:crypto'
import { prisma } from '../lib/prisma'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
publishAt: z.string().datetime().optional(),
category: z.enum(['tech', 'business', 'security']),
})
const UpdatePostSchema = CreatePostSchema.partial()
function etag(updatedAt: Date) {
return `"${crypto.createHash('sha1').update(updatedAt.toISOString()).digest('hex').slice(0, 16)}"`
}
export async function postsRoutes(fastify: FastifyInstance) {
// LIST — GET /posts
fastify.get('/posts', async (req, reply) => {
const posts = await prisma.post.findMany({
where: { deletedAt: null },
orderBy: { createdAt: 'desc' },
take: 50,
})
return reply.send(posts) // 200 OK
})
// READ — GET /posts/:id (with ETag and 304 validation)
fastify.get<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!post) {
return reply.code(404).send({ error: 'post_not_found' })
}
const currentEtag = etag(post.updatedAt)
if (req.headers['if-none-match'] === currentEtag) {
return reply.code(304).send() // without body
}
return reply.header('ETag', currentEtag).send(post) // 200 OK with ETag
})
// CREATE — POST /posts
fastify.post('/posts', async (req, reply) => {
const parsed = CreatePostSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ // 400 — bad structure
error: 'invalid_body',
issues: parsed.error.flatten(),
})
}
const data = parsed.data
// Business validation — 422, because format was OK
if (data.publishAt && new Date(data.publishAt) < new Date()) {
return reply.code(422).send({
error: 'publish_at_in_past',
message: 'publishAt must be in the future',
})
}
try {
const post = await prisma.post.create({
data: {
title: data.title,
content: data.content,
category: data.category,
publishAt: data.publishAt ? new Date(data.publishAt) : null,
authorId: req.user.id,
},
})
return reply
.code(201) // 201 Created
.header('Location', `/posts/${post.id}`)
.header('ETag', etag(post.updatedAt))
.send(post)
} catch (err: any) {
if (err.code === 'P2002') { // Prisma unique constraint
return reply.code(409).send({ // 409 Conflict
error: 'slug_already_exists',
})
}
throw err
}
})
// UPDATE — PATCH /posts/:id (optimistic concurrency via If-Match)
fastify.patch<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const parsed = UpdatePostSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.flatten() })
}
const existing = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!existing) {
return reply.code(404).send({ error: 'post_not_found' })
}
if (existing.authorId !== req.user.id && !req.user.isAdmin) {
return reply.code(403).send({ error: 'forbidden' })
}
// Optimistic lock — client sent If-Match with ETag
const ifMatch = req.headers['if-match']
if (ifMatch && ifMatch !== etag(existing.updatedAt)) {
return reply.code(412).send({ // 412 Precondition Failed
error: 'stale_version',
message: 'Someone changed the post in the meantime — fetch the current version',
})
}
const updated = await prisma.post.update({
where: { id: existing.id },
data: parsed.data,
})
return reply.header('ETag', etag(updated.updatedAt)).send(updated) // 200 OK
})
// DELETE — DELETE /posts/:id
fastify.delete<{ Params: { id: string } }>('/posts/:id', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { id: req.params.id, deletedAt: null },
})
if (!post) {
return reply.code(404).send({ error: 'post_not_found' })
}
if (post.authorId !== req.user.id && !req.user.isAdmin) {
return reply.code(403).send({ error: 'forbidden' })
}
await prisma.post.update({
where: { id: post.id },
data: { deletedAt: new Date() },
})
return reply.code(204).send() // 204 No Content
})
}
Why these codes and not others — decision by decision
Why 201 + Location instead of 200
200 OK says "the operation succeeded". 201 Created says "it succeeded and a new entity was created". The difference is practical: a client receiving 201 with Location: /posts/abc knows exactly that it can do GET /posts/abc and get the newly created resource. A client receiving 200 must extract the ID from the body — which requires a contract. A contract can change and break clients; Location is a standard.
Why 204 for DELETE instead of 200
Soft delete succeeded, we have nothing meaningful to return — 204 No Content. RFC 9110 forbids a body with 204, so fetch() in the browser automatically treats this response as "no body" and doesn't try to parse JSON. Returning 200 with {"deleted":true} forces the client to handle such a format; 204 is the standard.
Why I distinguish 400 and 422
Zod returns two types of errors. "Missing title field" — syntax, the request format is bad, 400 Bad Request. "publishAt in the past" — the format is OK (it's a valid ISO date), but the domain logic refuses. This is 422 Unprocessable Content. GitHub, Stripe, Rails consistently do this — and it makes sense, because the client treats 400 as a bug (we're structuring the request wrong), and 422 as a refusal (the user is trying to do something illegal in business terms).
Why 409 for unique constraint
Prisma throws P2002 when two users simultaneously insert a post with the same slug. Code 400 would be misleading — the request was correct. 422 would be odd — it's not a business rule, it's a state conflict. 409 Conflict is exactly that: "I can't do this because it collides with the current state".
Why 412 for optimistic lock
If-Match is a precondition. When it fails, the specification (RFC 9110 §13.1.1) says clearly: 412 Precondition Failed. Not 409 — the client isn't in a state of conflict, it got into one because it has an old version.
Why 304 instead of re-serving the entire response
A client with ETag "abc123" sends If-None-Match: "abc123". If the post hasn't changed — we return 304, empty body. This saves bandwidth (the post might be 50 KB of content), saves JSON serialization, saves gzip. Cloudflare/Varnish understand 304 and don't try to cache it as a response. In a typical CMS — 80%+ of requests to published posts can end with 304.
Test verifying decisions
// apps/api/tests/posts.test.ts
import { test, expect } from 'vitest'
import { buildApp } from '../src/app'
test('POST /posts returns 201 + Location', async () => {
const app = buildApp()
const res = await app.inject({
method: 'POST', url: '/posts',
headers: { authorization: 'Bearer test' },
payload: { title: 'X', content: 'Y', category: 'tech' },
})
expect(res.statusCode).toBe(201)
expect(res.headers.location).toMatch(/^\/posts\//)
expect(res.headers.etag).toBeTruthy()
})
test('POST /posts with publishAt in the past → 422 (not 400)', async () => {
const res = await app.inject({
method: 'POST', url: '/posts',
headers: { authorization: 'Bearer test' },
payload: {
title: 'X', content: 'Y', category: 'tech',
publishAt: '2020-01-01T00:00:00Z', // past
},
})
expect(res.statusCode).toBe(422) // semantics, not syntax
expect(res.json().error).toBe('publish_at_in_past')
})
test('DELETE returns 204 without body', async () => {
const res = await app.inject({ method: 'DELETE', url: '/posts/existing-id' })
expect(res.statusCode).toBe(204)
expect(res.body).toBe('') // empty string, not "{}"
})
test('GET with If-None-Match returns 304 without body', async () => {
const first = await app.inject({ method: 'GET', url: '/posts/abc' })
const etag = first.headers.etag
const second = await app.inject({
method: 'GET', url: '/posts/abc',
headers: { 'if-none-match': etag },
})
expect(second.statusCode).toBe(304)
expect(second.body).toBe('')
})
test('PATCH with stale If-Match → 412', async () => {
const res = await app.inject({
method: 'PATCH', url: '/posts/abc',
headers: { 'if-match': '"old-version"' },
payload: { title: 'New' },
})
expect(res.statusCode).toBe(412)
})
2. Login and sessions — 4 real bugs, 4 fixes
A login endpoint is one place where four different security bugs reveal themselves through unremarkable HTTP code / response shape / logging decisions. Below is the full path: first the version with bugs (exactly the kind we write on the first iteration), then each bug pulled onto the table and fixed, then the final version with a test.
Version with bugs (DON'T USE)
// apps/api/src/routes/auth/login.ts — version with 4 bugs
export async function loginRoute(fastify: FastifyInstance) {
fastify.post('/login', async (req, reply) => {
const { email, password } = req.body as { email: string; password: string }
req.log.info({ email, password }, 'login attempt') // BUG #4
const user = await prisma.user.findUnique({ where: { email } })
if (!user) { // BUG #1
return reply.code(404).send({ error: 'user_not_found' })
}
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) { // BUG #2
return reply.code(401).send({ error: 'wrong_password' })
}
const attempts = await redis?.incr(`login:${email}`).catch(() => null)
if (attempts === null) { // BUG #3
// Redis is down — allow further, security < availability
} else if (attempts > 5) {
return reply.code(429).send({ error: 'too_many' })
}
const token = fastify.jwt.sign({ userId: user.id })
return reply.send({ token })
})
}
Bug #1 — username enumeration via different codes
The endpoint returns 404 user_not_found when the email doesn't exist, 401 wrong_password when it does but the password is wrong. An attacker with a list of 500k emails (leak from another service) will filter in a few hours all active accounts of your company. HTTP statuses go to proxies/CDNs/access logs — no need to even read the body.
Fix:
// DUMMY_HASH — precomputed bcrypt hash of string "dummy" (cost 10)
const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
const user = await prisma.user.findUnique({ where: { email } })
const hashToCompare = user?.passwordHash ?? DUMMY_HASH
const ok = await bcrypt.compare(password, hashToCompare)
if (!user || !ok) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
Now the attacker gets identical 401 invalid_credentials regardless of whether the account exists. There's no way to tell.
Bug #2 — timing attack
The fix from #1 only apparently solved the problem. Reading it carefully — when user doesn't exist, hashToCompare = DUMMY_HASH, bcrypt.compare takes ~80 ms. When user exists, bcrypt compares with their real hash — also ~80 ms (as long as the cost is the same). OK, in this specific fragment timing is equalized.
But note: in the original buggy version, the first branch if (!user) return ... executes in ~2 ms (just a database query), the second ~82 ms (query + bcrypt). An attacker measures average time from 100 tries:
// attacker.ts — shows the difference
async function measureLogin(email: string) {
const samples = 100
const start = Date.now()
for (let i = 0; i < samples; i++) {
await fetch('/login', { method: 'POST', body: JSON.stringify({ email, password: 'wrong' }) })
}
return (Date.now() - start) / samples
}
console.log('exists:', await measureLogin('real@company.pl')) // ~82ms
console.log('notexists:', await measureLogin('random@xxxxxx.pl')) // ~2ms
A difference of 80 ms is an eternity — the attacker enumerates accounts without looking at HTTP status. The fix requires dummy bcrypt.compare for non-existent users (see the code above — it's already correct). Additionally, a regression test:
test('login does not reveal user existence through timing', async () => {
const warmup = 10, samples = 200
// warmup
for (let i = 0; i < warmup; i++) await login('whatever@nowhere.xx', 'wrong')
const tExists: number[] = []
const tNotExists: number[] = []
for (let i = 0; i < samples; i++) {
const t1 = performance.now()
await login('real@company.pl', 'wrong')
tExists.push(performance.now() - t1)
const t2 = performance.now()
await login('random' + i + '@xxxxxx.xx', 'wrong')
tNotExists.push(performance.now() - t2)
}
const median = (arr: number[]) => arr.sort()[arr.length / 2 | 0]
const diff = Math.abs(median(tExists) - median(tNotExists))
expect(diff).toBeLessThan(5) // less than 5ms difference
})
Bug #3 — fail-open rate limit
redis?.incr(...).catch(() => null) — when Redis goes down, attempts === null, the condition attempts > 5 is false, limit disabled. Real incident (from an audit at PageForYou.pl in April 2026): OOM on Redis + attacker at the same moment + 200k password attempts before anyone noticed.
Fix — fail closed:
const key = `login:${email}:${req.ip}`
let attempts: number
try {
attempts = await redis.incr(key)
if (attempts === 1) await redis.expire(key, 60) // sliding window 1 min
} catch {
// Redis is down — do NOT allow further
return reply
.code(429)
.header('Retry-After', '30')
.send({ error: 'rate_limiter_unavailable' })
}
if (attempts > 5) {
return reply
.code(429)
.header('Retry-After', '60')
.send({ error: 'too_many_attempts' })
}
Trade-off, conscious: when Redis goes down nobody can log in for ~30 s (Redis recovery time + expire). We accept this — security > availability. The alternative is a local fallback (in-memory map), but then a distributed system has uncoordinated limit — each pod counts separately, in reality N× 5 attempts instead of 5.
Bug #4 — password in logs
req.log.info({ email, password }, ...) → Loki/ELK/Splunk get the password in plaintext. Everyone with access to monitoring (SRE, DevOps, contractor) will see it. Classic leak.
Fix — Fastify logger redact:
import Fastify from 'fastify'
const fastify = Fastify({
logger: {
level: 'info',
redact: {
paths: [
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
'req.headers.authorization',
'req.headers.cookie',
],
censor: '[REDACTED]',
},
},
})
Redact works at the Pino serializer level — passwords never reach the transport, even by accident in other parts of the code.
Final version of the endpoint
// apps/api/src/routes/auth/login.ts — after all 4 fixes
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { prisma } from '../../lib/prisma'
import { redis } from '../../lib/redis'
const DUMMY_HASH = '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(1) })
export async function loginRoute(fastify: FastifyInstance) {
fastify.post('/login', async (req, reply) => {
const parsed = LoginSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_body' })
}
const { email, password } = parsed.data
// 1. Rate limit (fail-closed)
const rateKey = `login:${email.toLowerCase()}:${req.ip}`
try {
const attempts = await redis.incr(rateKey)
if (attempts === 1) await redis.expire(rateKey, 60)
if (attempts > 5) {
return reply
.code(429)
.header('Retry-After', '60')
.header('WWW-Authenticate', 'Bearer realm="api"')
.send({ error: 'too_many_attempts' })
}
} catch {
return reply
.code(429)
.header('Retry-After', '30')
.send({ error: 'rate_limiter_unavailable' })
}
// 2. Constant-time user lookup + compare
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } })
const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH)
if (!user || !ok) {
return reply
.code(401)
.header('WWW-Authenticate', 'Bearer realm="api"')
.send({ error: 'invalid_credentials' })
}
if (user.disabledAt) {
return reply.code(403).send({ error: 'account_disabled' })
}
// Success — reset rate limit + issue token
await redis.del(rateKey).catch(() => {})
const token = fastify.jwt.sign({ userId: user.id, role: user.role }, { expiresIn: '15m' })
return reply.send({ token, expiresIn: 900 })
})
}
Why 401 and not 403 for failed login
Classic mistake: if the user entered "wrong password", maybe 403 Forbidden? No. 401 means "I don't know who you are, authenticate yourself". 403 means "I know who you are, but you don't have permission to this resource". In login, authentication failed — 401. I use 403 only for disabled account, where identity was OK, but policy refuses further.
3. Redirects — 5 scenarios without shooting yourself in the foot
Five different redirects, each with a different reason. Choosing the wrong code changes POST to GET, breaks SEO, or opens an open redirect.
Scenario 1: HTTP → HTTPS (global)
Code: 301 + HSTS. Permanent, for all methods (GET dominates; for POST it's the same fix, browser will do what it needs to).
# /etc/nginx/sites-enabled/example.pl
server {
listen 80;
server_name example.pl www.example.pl;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.pl www.example.pl;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# ...
}
Pitfall: the first HTTP request may contain session cookies in plaintext. HSTS solves this only from the second visit — after the first successful HTTPS response the browser remembers and no longer tries HTTP. The first visit must be either via explicit https:// or preload list (hstspreload.org).
Scenario 2: Old URL → new one after rebranding
Code: 301. Google will deindex the old URL and rewrite page rank.
fastify.get('/blog-old/:slug', (req, reply) => {
return reply.redirect(301, `/blog/${req.params.slug}`)
})
Pitfall: 301 is aggressively cached by the browser (sometimes forever). If you mess up the target URL, users will land there even after you fix it, as long as they don't clear cache. If you're not 100% sure — use 302 at first, change to 301 when you're certain.
Scenario 3: Post-Redirect-Get after form
Code: 303 See Other. Forces GET instead of POST — F5 on the confirmation page doesn't cause duplicate order.
fastify.post('/checkout', async (req, reply) => {
const order = await createOrder(req.body)
return reply.code(303).header('Location', `/orders/${order.id}`).send()
})
Difference from 302: 302 historically meant "preserve the method", but browsers always changed POST to GET. RFC 9110 documented this inconsistency. 303 is explicitly about this — no ambiguity.
Scenario 4: Permanent API endpoint change with POST
Code: 308 Permanent Redirect. Unlike 301, it guarantees method and body preservation.
// Old endpoint /api/v1/orders/create (POST) → /api/v2/orders (POST)
fastify.post('/api/v1/orders/create', (req, reply) => {
return reply.redirect(308, '/api/v2/orders')
})
A client that sent POST with 2 KB body will get 308 Location: /api/v2/orders and execute POST with the same body. 301 is NOT SAFE here — some HTTP libraries (older curl, old axios) will change POST to GET.
Scenario 5: Open redirect — classic phishing attack
The most common application vulnerability I see on audit. Click tracking endpoint:
// VULNERABLE — DON'T USE
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
return reply.redirect(302, req.query.url)
})
Attack: SMS "Your bank.pl account will be blocked, verify: https://bank.pl/go?url=https://bank-login-verify.evil/auth". Victim sees trusted domain, clicks, gets 302 Location: https://bank-login-verify.evil/auth, lands on phishing. Entering password = account compromised.
Fix — allowlist:
const ALLOWED_HOSTS = new Set([
'bank.pl', 'mobile.bank.pl', 'blog.bank.pl',
])
fastify.get<{ Querystring: { url: string } }>('/go', (req, reply) => {
let target: URL
try {
target = new URL(req.query.url)
} catch {
return reply.code(400).send({ error: 'invalid_url' })
}
// Only HTTPS, only known hosts
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.host)) {
return reply.code(400).send({ error: 'domain_not_allowed' })
}
return reply.redirect(302, target.toString())
})
Choice of 302 (not 301) is deliberate — we don't want the browser to cache "click here → land there", it should be a fresh decision with every click.
4. Upload and range requests — including Apache Killer
File upload is where 413 (file too large), 415 (wrong type), 416 (invalid range) appear and 206 (partial content — video with seeking). Plus Apache Killer — a 15-year-old attack that still works on misconfigured servers.
Upload endpoint with validation
import multipart from '@fastify/multipart'
import { pipeline } from 'node:stream/promises'
import fs from 'node:fs'
import crypto from 'node:crypto'
const ALLOWED_MIME = new Set([
'image/jpeg', 'image/png', 'image/webp',
'application/pdf',
])
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
export async function uploadRoute(fastify: FastifyInstance) {
await fastify.register(multipart, {
limits: {
fileSize: MAX_SIZE,
files: 1,
fields: 5,
headerPairs: 200,
},
})
fastify.post('/upload', async (req, reply) => {
const data = await req.file()
if (!data) return reply.code(400).send({ error: 'no_file' })
if (!ALLOWED_MIME.has(data.mimetype)) {
return reply.code(415).send({ // 415 Unsupported Media Type
error: 'mime_not_allowed',
allowed: [...ALLOWED_MIME],
})
}
const id = crypto.randomBytes(16).toString('hex')
const ext = data.mimetype.split('/')[1]
const path = `/srv/uploads/${id}.${ext}`
try {
await pipeline(data.file, fs.createWriteStream(path))
} catch (err: any) {
if (err.code === 'FST_REQ_FILE_TOO_LARGE') {
return reply.code(413).send({ // 413 Content Too Large
error: 'file_too_large',
maxBytes: MAX_SIZE,
})
}
throw err
}
// Sanity check — multipart limit might be bypassed, check real size
const stat = await fs.promises.stat(path)
if (stat.size > MAX_SIZE) {
await fs.promises.unlink(path)
return reply.code(413).send({ error: 'file_too_large' })
}
// Magic bytes check — mimetype from header might lie
const realMime = await detectMimeFromBytes(path) // e.g. file-type lib
if (!ALLOWED_MIME.has(realMime)) {
await fs.promises.unlink(path)
return reply.code(415).send({ error: 'mime_mismatch' })
}
return reply.code(201)
.header('Location', `/files/${id}`)
.send({ id, size: stat.size, mime: realMime })
})
}
Why magic bytes check — don't trust the client
data.mimetype is the Content-Type from the multipart header — the client can write any string there. An attacker will send .exe with Content-Type: image/png, and later in another part of the app that file gets served to img src — which is safe — but to iframe or Content-Type in the response, and the browser executes the binary. Verification via file-type/libmagic reads the first bytes and really recognizes the type.
Range requests — serving video with seeking
A video player sends Range: bytes=1048576-2097151 — wants the middle fragment. Server responds 206 Partial Content.
fastify.get<{ Params: { id: string } }>('/videos/:id', async (req, reply) => {
const video = await prisma.video.findUnique({ where: { id: req.params.id } })
if (!video) return reply.code(404).send({ error: 'not_found' })
const stat = await fs.promises.stat(video.path)
const range = req.headers.range
if (!range) {
return reply
.header('Content-Length', stat.size.toString())
.header('Accept-Ranges', 'bytes')
.type('video/mp4')
.send(fs.createReadStream(video.path)) // 200 OK, whole thing
}
const match = /^bytes=(\d+)-(\d*)$/.exec(range)
if (!match) {
return reply.code(416).send({ error: 'invalid_range' })
}
const start = parseInt(match[1], 10)
const end = match[2] ? parseInt(match[2], 10) : stat.size - 1
if (start >= stat.size || end >= stat.size || start > end) {
return reply
.code(416)
.header('Content-Range', `bytes */${stat.size}`)
.send()
}
const chunkSize = end - start + 1
return reply
.code(206) // 206 Partial Content
.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)
.header('Accept-Ranges', 'bytes')
.header('Content-Length', chunkSize.toString())
.type('video/mp4')
.send(fs.createReadStream(video.path, { start, end }))
})
Apache Killer — CVE-2011-3192
An attacker sends a header with thousands of overlapping ranges:
Range: bytes=0-,0-1,0-2,0-3,0-4,0-5,0-6,0-7,...×1300
The server tries to prepare and combine all these fragments — OOM, crash. The fix in Apache/Nginx was settled long ago, but if you write your own Range handling (above) — you must limit the number and size of ranges. My handler accepts only a single range, which is safe. Full RFC 7233 supports multipart/byteranges with multiple ranges — only implement if you really need it, with a max 10 ranges limit.
5. Async jobs — 202 Accepted + polling
A GDPR export ZIP is ~62 seconds on a large account (PageForYou GDPR export). Synchronously — the browser times out, Cloudflare 524. Correctly — 202 Accepted + status endpoint + client-side polling.
Endpoint creating a job
import { Queue } from 'bullmq'
const exportQueue = new Queue('exports', { connection: redisConfig })
fastify.post('/exports', async (req, reply) => {
const job = await exportQueue.add('generate-rodo-zip', {
userId: req.user.id,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
})
return reply
.code(202) // 202 Accepted
.header('Location', `/exports/${job.id}`)
.send({
jobId: job.id,
status: 'queued',
statusUrl: `/exports/${job.id}`,
})
})
Status endpoint — what to return, depending on stage
fastify.get<{ Params: { id: string } }>('/exports/:id', async (req, reply) => {
const job = await exportQueue.getJob(req.params.id)
if (!job) return reply.code(404).send({ error: 'job_not_found' })
// Authorization — only the job owner
if (job.data.userId !== req.user.id) {
return reply.code(404).send({ error: 'job_not_found' }) // 404 deliberately, not 403
}
const state = await job.getState()
if (state === 'completed') {
// Ready — redirect to download URL
return reply.code(303).header('Location', job.returnvalue.downloadUrl).send()
}
if (state === 'failed') {
return reply.code(500).send({
error: 'job_failed',
attempts: job.attemptsMade,
failedReason: job.failedReason,
})
}
// Still running
return reply
.code(200)
.header('Retry-After', '5') // hint to client — poll in 5s
.send({
jobId: job.id,
status: state, // waiting, active, delayed
progress: job.progress,
})
})
Why 404 instead of 403 for someone else's job
An attacker knowing the job ID format could enumerate /exports/0, /exports/1, ... and by status codes (403 vs 404) map other users' activity. By always returning 404 when the job isn't yours — the attacker can't tell "doesn't exist" from "not yours".
Why 303 after completion, not 200
A client asking about status wants the URL to download when done. 303 See Other with Location: signed-url says "go there via GET". The client can follow-redirect automatically, the user gets the file. 200 with { downloadUrl: "..." } requires the client to make a separate GET and separate logic — 303 is cleaner.
6. Cache validation — ETag and 304
A complete example of cache validation in a blog API, like the one you're reading. Goal: reduce bandwidth 10×, lower SSR render time.
Endpoint with ETag and validation
import crypto from 'node:crypto'
function computeEtag(post: { id: string; updatedAt: Date; version: number }) {
const raw = `${post.id}:${post.updatedAt.toISOString()}:${post.version}`
return `"${crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16)}"`
}
fastify.get<{ Params: { slug: string } }>('/posts/slug/:slug', async (req, reply) => {
const post = await prisma.post.findFirst({
where: { slug: req.params.slug, status: 'published' },
select: { id: true, updatedAt: true, version: true, /* and other fields */
title: true, content: true, excerpt: true, coverImage: true },
})
if (!post) return reply.code(404).send({ error: 'not_found' })
const currentEtag = computeEtag(post)
const ifNoneMatch = req.headers['if-none-match']
// Cache validation — does client have current version?
if (ifNoneMatch === currentEtag) {
return reply
.code(304) // 304 Not Modified
.header('ETag', currentEtag)
.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
.send() // empty body
}
return reply
.code(200)
.header('ETag', currentEtag)
.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
.header('Vary', 'Accept-Language')
.send(post)
})
How Next.js / Cloudflare uses this
Next.js ISR with revalidate: 60 will fetch your API every 60 s. With ETag:
- First fetch →
200 OK+ ETag + body (50 KB) - Subsequent revalidation fetch (every 60 s) →
If-None-Match: "abc"→304+ 0 bytes
Savings: ~50 KB × number of revalidations × number of servers. With 10 Cloudflare regions × 60 revalidations/h = 600 fetches/h/post. 50 KB × 600 = 30 MB/h → without ETag. With ETag → 600 × ~200 B (just the header) = 120 KB/h. 250× less.
What MUST go into ETag
Every change observable by the client. Minimum:
id— different posts have different etags even ifupdatedAthappens to be identicalupdatedAt— bump on editversion— in case of cascading changes (comments, view counter) usually better to have separate field than rely onupdatedAt
What NOT to put in ETag: password hash, secrets, other users' data. ETag is public — goes to CDN logs, browser headers.
7. Error handling in production (500, 502, 503, 504)
These four codes look similar, but each means something different and each has a different client / monitoring reaction.
500 — your code crashed
Exception in handler. Setup that doesn't leak stack trace in prod and gives requestId for correlation:
fastify.setErrorHandler((err, req, reply) => {
req.log.error({ err, reqId: req.id, url: req.url, method: req.method }, 'unhandled')
// Business errors — pass through
if (err.statusCode && err.statusCode < 500) {
return reply.code(err.statusCode).send({
error: err.code || 'error',
message: err.message,
})
}
// Real 500 — hide details
return reply.code(500).send({
error: 'internal_error',
requestId: req.id, // for logs
// NOT: err.message, err.stack, err.sql, file paths
})
})
When a user reports "I got an error", you ask "what's the request ID?" and find the full stack trace in logs. The user sees nothing sensitive.
502 and 504 — Nginx → backend
Both automatic from Nginx. 502 = backend returned garbage (upstream crashed mid-request, reset connection, invalid HTTP response). 504 = backend didn't respond in time (proxy_read_timeout).
# /etc/nginx/sites-enabled/api
upstream api {
server 127.0.0.1:4000 max_fails=3 fail_timeout=30s;
server 127.0.0.1:4001 backup;
}
server {
listen 443 ssl;
server_name api.example.pl;
location / {
proxy_pass http://api;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# Retry on next upstream
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
# Strip leak headers
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
}
}
503 — temporary, Retry-After
503 is the ONLY one of the 5xx that clearly tells the client "try again". Use when:
- Planned maintenance (blue-green deploy)
- Circuit breaker open (dependent service is down)
- Load shedding (too much RPS, protecting the rest)
Usually with Retry-After + custom body:
// Circuit breaker — when DB is overloaded
fastify.addHook('preHandler', async (req, reply) => {
if (await circuitBreaker.isOpen()) {
return reply
.code(503)
.header('Retry-After', '30')
.send({
error: 'service_degraded',
retryAfter: 30,
})
}
})
Key difference 500 vs 503: 500 = bug, SRE gets paged. 503 = expected, monitoring distinguishes (no alerting). Returning 503 instead of 500 to "not alarm" — an anti-pattern. Alarms are there to alarm.
8. Reference table — all 63 codes with RFC assignment
When you need to quickly check a specific code — the table below. All 63 official entries from the IANA registry with a reference to the defining RFC.
1xx — Informational (4 codes)
| Code | Name | RFC | When |
|---|---|---|---|
| 100 | Continue | RFC 9110 §15.2.1 | Client sent Expect: 100-continue, server agrees to body |
| 101 | Switching Protocols | RFC 9110 §15.2.2 | WebSocket handshake, HTTP/2 upgrade |
| 102 | Processing ⚠ | RFC 2518 (deprecated) | WebDAV, removed from RFC 4918 |
| 103 | Early Hints | RFC 8297 | Preload hint before final response (Chrome 103+) |
2xx — Success (10 codes)
| Code | Name | RFC | When |
|---|---|---|---|
| 200 | OK | RFC 9110 §15.3.1 | Success with body |
| 201 | Created | RFC 9110 §15.3.2 | New resource, + Location |
| 202 | Accepted | RFC 9110 §15.3.3 | Async job queued |
| 203 | Non-Authoritative | RFC 9110 §15.3.4 | Response from transforming proxy |
| 204 | No Content | RFC 9110 §15.3.5 | DELETE OK, no body |
| 205 | Reset Content | RFC 9110 §15.3.6 | Reset form (rare) |
| 206 | Partial Content | RFC 9110 §15.3.7 | Range request |
| 207 | Multi-Status | RFC 4918 §11.1 | WebDAV batch |
| 208 | Already Reported | RFC 5842 §7.1 | WebDAV binding, avoid duplicates |
| 226 | IM Used | RFC 3229 | Delta encoding (rare) |
3xx — Redirects (9 codes)
| Code | Name | RFC | When |
|---|---|---|---|
| 300 | Multiple Choices | RFC 9110 §15.4.1 | Multiple representations (rare) |
| 301 | Moved Permanently | RFC 9110 §15.4.2 | Permanent, GET (may change POST→GET) |
| 302 | Found | RFC 9110 §15.4.3 | Temporary, GET (ambiguous) |
| 303 | See Other | RFC 9110 §15.4.4 | Post-Redirect-Get |
| 304 | Not Modified | RFC 9110 §15.4.5 | Cache validation ETag/If-Modified-Since |
| 305 | Use Proxy ⚠ | RFC 9110 §15.4.6 (deprecated) | Security vulnerability, don't use |
| 306 | (Unused) | RFC 9110 §15.4.7 | Reserved |
| 307 | Temporary Redirect | RFC 9110 §15.4.8 | Temporary, preserves method |
| 308 | Permanent Redirect | RFC 9110 §15.4.9 | Permanent, preserves method |
4xx — Client errors (28 codes)
| Code | Name | RFC | When |
|---|---|---|---|
| 400 | Bad Request | RFC 9110 §15.5.1 | Wrong request format |
| 401 | Unauthorized | RFC 9110 §15.5.2 | Missing/wrong token, + WWW-Authenticate |
| 402 | Payment Required | RFC 9110 §15.5.3 | Reserved, Stripe "card declined" |
| 403 | Forbidden | RFC 9110 §15.5.4 | Token OK, no permission |
| 404 | Not Found | RFC 9110 §15.5.5 | Resource doesn't exist |
| 405 | Method Not Allowed | RFC 9110 §15.5.6 | + Allow |
| 406 | Not Acceptable | RFC 9110 §15.5.7 | Content negotiation doesn't match |
| 407 | Proxy Auth Required | RFC 9110 §15.5.8 | Like 401 for proxy |
| 408 | Request Timeout | RFC 9110 §15.5.9 | Slow body sending |
| 409 | Conflict | RFC 9110 §15.5.10 | Unique violation, state machine |
| 410 | Gone | RFC 9110 §15.5.11 | Deleted forever |
| 411 | Length Required | RFC 9110 §15.5.12 | Missing Content-Length |
| 412 | Precondition Failed | RFC 9110 §15.5.13 | If-Match / If-Unmodified-Since |
| 413 | Content Too Large | RFC 9110 §15.5.14 | Body exceeds limit |
| 414 | URI Too Long | RFC 9110 §15.5.15 | URL too long |
| 415 | Unsupported Media Type | RFC 9110 §15.5.16 | Wrong Content-Type |
| 416 | Range Not Satisfiable | RFC 9110 §15.5.17 | Wrong Range |
| 417 | Expectation Failed | RFC 9110 §15.5.18 | Doesn't meet Expect |
| 418 | I'm a teapot | RFC 2324 (joke) | Honeypot for scanners |
| 421 | Misdirected Request | RFC 9110 §15.5.20 | HTTP/2 connection coalescing |
| 422 | Unprocessable Content | RFC 9110 §15.5.21 | Business logic validation |
| 423 | Locked | RFC 4918 §11.3 | WebDAV |
| 424 | Failed Dependency | RFC 4918 §11.4 | WebDAV |
| 425 | Too Early | RFC 8470 | TLS 1.3 0-RTT replay protection |
| 426 | Upgrade Required | RFC 9110 §15.5.22 | Enforce newer protocol |
| 428 | Precondition Required | RFC 6585 §3 | Enforce If-Match (lost update) |
| 429 | Too Many Requests | RFC 6585 §4 | Rate limit, + Retry-After |
| 431 | Request Headers Too Large | RFC 6585 §5 | Headers exceeded limit |
| 451 | Unavailable For Legal Reasons | RFC 7725 | GDPR, DMCA, geoblock |
5xx — Server errors (11 codes)
| Code | Name | RFC | When |
|---|---|---|---|
| 500 | Internal Server Error | RFC 9110 §15.6.1 | Exception in code |
| 501 | Not Implemented | RFC 9110 §15.6.2 | Unknown HTTP method |
| 502 | Bad Gateway | RFC 9110 §15.6.3 | Proxy got garbage from backend |
| 503 | Service Unavailable | RFC 9110 §15.6.4 | Temporary, + Retry-After |
| 504 | Gateway Timeout | RFC 9110 §15.6.5 | Proxy → backend timeout |
| 505 | HTTP Version Not Supported | RFC 9110 §15.6.6 | Wrong HTTP/X |
| 506 | Variant Also Negotiates | RFC 2295 §8.1 | Content negotiation error (rare) |
| 507 | Insufficient Storage | RFC 4918 §11.5 | No disk space (upload) |
| 508 | Loop Detected | RFC 5842 §7.2 | WebDAV recursion |
| 510 | Not Extended ⚠ | RFC 2774 (deprecated) | HTTP Extension Framework |
| 511 | Network Authentication Required | RFC 6585 §6 | Captive portal (WiFi hotel/airport) |
Sources
- IANA, Hypertext Transfer Protocol (HTTP) Status Code Registry, iana.org/assignments/http-status-codes
- R. Fielding, M. Nottingham, J. Reschke, RFC 9110: HTTP Semantics, June 2022, rfc-editor.org/rfc/rfc9110
- L. Dusseault, RFC 4918: HTTP Extensions for WebDAV, June 2007, rfc-editor.org/rfc/rfc4918
- M. Nottingham, R. Fielding, RFC 6585: Additional HTTP Status Codes, April 2012, rfc-editor.org/rfc/rfc6585
- T. Bray, RFC 7725: An HTTP Status Code to Report Legal Obstacles, February 2016
- K. Oku, RFC 8297: An HTTP Status Code for Indicating Hints, December 2017
- CVE-2011-3192, Apache HTTP Server Range Header DoS (Apache Killer), nvd.nist.gov
- OWASP, Authentication Cheat Sheet, cheatsheetseries.owasp.org
- OWASP, Unvalidated Redirects and Forwards, cheatsheetseries.owasp.org
- J. Kettle, Practical Web Cache Poisoning, PortSwigger Research 2018
- Fastify, Reply API documentation, fastify.dev/docs
- Node.js, BullMQ job queue documentation, docs.bullmq.io