| Series | Topic | Level | Read Time |
| Web & HTTP Foundations — Part 3 of 12 | HTTP Headers — All Categories Deep Dive | Beginner to Intermediate | ~16 minutes |
| 📌 This is Part 3 of the Web & HTTP Foundations series. Part 1 covered the HTTP Request Lifecycle. Part 2 covered HTTP Methods and Idempotency. Read those first if you haven’t — this post builds on them. |
Headers are the most underrated part of HTTP.
Most developers learn about them when something breaks — a CORS error in the browser, a caching bug that serves stale data, a 401 because the Authorization header was formatted wrong.
But headers are not just metadata noise around your actual request. They are instructions. They tell the browser how to cache, tell the server who you are, tell the CDN what to store, tell the client what format to expect, and tell everyone involved what security policies to enforce.
In this post we cover every header category a backend developer must know — with real examples, Node.js code, and the mistakes that show up in production systems.
HTTP Header Categories — The Big Picture
HTTP headers are organised into four categories based on who sends them and what they control:
| Category | Direction | Purpose | Examples |
| Request Headers | Client → Server | Describe the request, carry auth, declare capabilities | Content-Type, Authorization, Accept, User-Agent |
| Response Headers | Server → Client | Describe the response, control caching and behaviour | Cache-Control, ETag, Content-Type, Set-Cookie |
| Representation Headers | Both | Describe the format and encoding of the body | Content-Type, Content-Length, Content-Encoding |
| Security Headers | Server → Client | Enforce browser security policies | HSTS, CSP, X-Frame-Options, X-Content-Type-Options |
| 💡 Some headers appear in both requests and responses (like Content-Type). The same header name can mean slightly different things depending on direction. We will call this out clearly for each one. |
Part 1 — Request Headers
These are sent by the client (browser, mobile app, Postman, curl, your API client) to the server with every request.
| Content-Type |
Tells the server what format the request body is in. The server uses this to parse the body correctly.
| Value | Use Case |
| application/json | Sending JSON data — used in almost all REST APIs |
| application/x-www-form-urlencoded | HTML form submissions (default browser form POST) |
| multipart/form-data | File uploads — allows binary data alongside text fields |
| text/plain | Plain text body — rare in APIs |
| application/xml | XML data — used in legacy SOAP APIs and some integrations |
HTTP Request
POST /api/users HTTP/1.1
Content-Type: application/json
{"name": "Rahul", "email": "rahul@example.com"}
// Express automatically parses based on Content-Type
app.use(express.json()); // parses application/json
app.use(express.urlencoded({ extended: true })); // parses form data
app.post('/api/users', (req, res) => {
// req.body is populated correctly based on Content-Type
console.log(req.body); // { name: 'Rahul', email: 'rahul@example.com' }
});
| ⚠️ If Content-Type is missing or wrong, express.json() will not parse the body and req.body will be empty or undefined. This is one of the most common bugs beginners hit. Always set Content-Type: application/json when sending JSON. |
| Authorization |
Carries the credentials that authenticate who is making the request. This is one of the most important headers you will work with as a backend developer.
| Scheme | Format | Use Case |
| Bearer | Authorization: Bearer <token> | JWT tokens — most common in modern REST APIs |
| Basic | Authorization: Basic <base64> | Username:password encoded in Base64 — avoid in production |
| API Key | Authorization: ApiKey <key> | Simple API key auth — used by many third-party services |
| Digest | Authorization: Digest … | Challenge-response — rare, mostly in older systems |
HTTP Request
# Bearer token (JWT) — most common
GET /api/profile HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI0MiJ9.abc123
# Basic auth — base64 of "username:password"
GET /api/admin HTTP/1.1
Authorization: Basic cmFodWw6cGFzc3dvcmQxMjM=
// Reading and validating Bearer token in Express
function authenticate(req, res, next) {
const authHeader = req.headers['authorization'];
// check header exists and starts with 'Bearer '
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Authorization header missing or malformed'
});
}
// extract the token part after 'Bearer '
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // attach decoded payload to request
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
| ❌ Never put tokens in the URL: GET /api/data?token=abc123. URLs are logged in server logs, browser history, and CDN access logs. Use the Authorization header — it is NOT logged by default. |
| Accept |
Tells the server what response formats the client can understand. The server picks the best match and sets the response Content-Type accordingly.
HTTP Request
GET /api/users/42 HTTP/1.1
Accept: application/json
# Client accepts both JSON and XML, prefers JSON (q=0.9 is lower priority)
GET /api/report HTTP/1.1
Accept: application/json, application/xml;q=0.9, */*;q=0.8
Most REST APIs only support JSON so Accept is often ignored. But if you build APIs that serve multiple formats (JSON for apps, CSV for exports), Accept is how clients tell you which one they want.
| Accept-Encoding |
Tells the server which compression algorithms the client supports. The server can then compress the response body before sending it — reducing bandwidth significantly.
HTTP Request
GET /api/products HTTP/1.1
Accept-Encoding: gzip, deflate, br
| Encoding | Compression Ratio | Speed | Notes |
| br (Brotli) | Best — 20-26% better than gzip | Slower to compress | Modern — supported in all current browsers |
| gzip | Good | Fast | Standard — supported everywhere |
| deflate | Similar to gzip | Fast | Rarely used — gzip preferred |
| identity | No compression | Fastest | Raw data — used for small responses |
// Enable compression in Express
const compression = require('compression');
app.use(compression({
level: 6, // compression level 1-9 (6 is good balance)
threshold: 1024, // only compress responses > 1KB
filter: (req, res) => {
// don't compress already-compressed content
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
}
}));
// Now Express automatically sends gzip/brotli based on Accept-Encoding
| 💡 Enable compression on your server. A typical JSON API response compresses by 70-80%. A 100KB response becomes 20KB. This cuts bandwidth costs and response time significantly — especially for mobile users on slower connections. |
| User-Agent |
Identifies what client is making the request — browser name and version, OS, or the name of an HTTP client library.
HTTP Request
# Chrome browser on Windows
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0
# Node.js axios library
User-Agent: axios/1.6.7
# curl command line tool
User-Agent: curl/8.4.0
As a backend developer you use User-Agent to log which clients are calling your API, block bots or scrapers, or serve different responses to different clients. Never use it as the sole security check — it is trivially spoofed.
| Other Important Request Headers |
| Header | Purpose | Example Value |
| Host | Which domain the request is for — required in HTTP/1.1 | api.example.com |
| Origin | Where the request originated — used in CORS checks | https://myfrontend.com |
| Referer | The URL the user came from | https://google.com/search?q=… |
| Cookie | Send cookies to the server for session management | sessionId=abc123; userId=42 |
| If-None-Match | Send cached ETag back — server returns 304 if unchanged | W/”abc123xyz” |
| If-Modified-Since | Send cached date — server returns 304 if not modified since | Mon, 31 Mar 2026 00:00:00 GMT |
| X-Request-ID | Unique ID for distributed tracing and debugging | a1b2c3d4-e5f6-7890-abcd |
Part 2 — Response Headers
These are sent by the server back to the client. They control caching, describe the response, manage cookies, and enforce security policies.
| Cache-Control |
The most important caching header. Tells browsers, CDNs, and proxy servers how to cache the response. Getting this right can dramatically reduce server load and improve performance.
| Directive | Meaning | Use When |
| no-store | Never cache this response anywhere | Sensitive data: user profiles, payment info, private data |
| no-cache | Cache it but ALWAYS revalidate with server before using | Data that changes frequently but caching is still useful |
| private | Only the browser can cache — not CDNs or shared caches | User-specific data that should not be shared |
| public | Anyone can cache — browser and CDN | Public data: blog posts, product listings, static content |
| max-age=N | Cache for N seconds from request time | With ‘public’: static assets, with ‘private’: user sessions |
| s-maxage=N | Cache for N seconds in shared caches (CDN) only | When CDN TTL should differ from browser TTL |
| must-revalidate | Must check server when cache expires — no stale serving | Critical data where stale responses are unacceptable |
| stale-while-revalidate=N | Serve stale while fetching fresh in background | Non-critical data where speed matters more than freshness |
| immutable | Content will never change — skip revalidation entirely | Versioned static assets: /app.v3.js, /logo.v2.png |
// Cache-Control examples for different endpoint types
// Public API data — cache 5 minutes everywhere
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=300');
res.json(products);
});
// User-specific data — cache in browser only, 10 minutes
app.get('/api/profile', authenticate, (req, res) => {
res.set('Cache-Control', 'private, max-age=600');
res.json(req.user);
});
// Sensitive data — never cache anywhere
app.get('/api/payment-history', authenticate, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(payments);
});
// Versioned static asset — cache forever, never revalidate
app.get('/assets/app.v4.2.1.js', (req, res) => {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
res.sendFile(path.join(__dirname, 'public/app.v4.2.1.js'));
});
// API that changes often — cache but revalidate frequently
app.get('/api/live-scores', (req, res) => {
res.set('Cache-Control', 'public, max-age=10, stale-while-revalidate=5');
res.json(scores);
});
| ❌ Never send Cache-Control: no-cache on static assets like CSS/JS/images. This forces browsers to revalidate on EVERY page load, killing performance. Use versioned filenames + max-age=31536000 + immutable for static assets instead. |
| ETag (Entity Tag) |
ETag is a unique fingerprint (hash) of a resource’s content. When a client caches a response, it stores the ETag. On the next request, it sends the ETag back via If-None-Match. If the resource hasn’t changed, the server returns 304 Not Modified — no body — saving bandwidth.
HTTP Response
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "a3f4b2c1d0e9f8g7"
Cache-Control: private, max-age=0, must-revalidate
{"id": 42, "name": "Rahul", "email": "rahul@example.com"}
HTTP Request
# Client sends ETag back on next request
GET /api/users/42 HTTP/1.1
If-None-Match: "a3f4b2c1d0e9f8g7"
HTTP Response
# If resource unchanged — 304 with NO BODY
HTTP/1.1 304 Not Modified
ETag: "a3f4b2c1d0e9f8g7"
# If resource changed — 200 with new content and new ETag
HTTP/1.1 200 OK
ETag: "x9y8z7w6v5u4t3s2"
Content-Type: application/json
{"id": 42, "name": "Rahul Kumar", "email": "rahul.kumar@example.com"}
const crypto = require('crypto');
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
// generate ETag from content hash
const content = JSON.stringify(user);
const etag = '"' + crypto.createHash('md5').update(content).digest('hex') + '"';
// check if client's cached version matches
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // not modified — no body sent
}
res.set('ETag', etag);
res.set('Cache-Control', 'private, max-age=0, must-revalidate');
res.status(200).json(user);
});
| 💡 ETags are excellent for read-heavy APIs where data changes occasionally. A 304 response has no body — just headers. For a response that would normally be 50KB, a 304 is just ~200 bytes. That’s a 99.6% bandwidth saving on cache hits. |
| Content-Type (Response) |
In responses, Content-Type tells the client what format the response body is in. The client uses this to parse it correctly.
| Value | When to use |
| application/json | JSON response body — most REST APIs |
| text/html; charset=utf-8 | HTML pages |
| text/plain | Plain text responses |
| application/octet-stream | Binary file download |
| image/png, image/jpeg | Image responses |
| application/pdf | PDF file responses |
| text/csv | CSV data export |
| ❌ Never send application/json without a charset or with the wrong Content-Type. If your body is JSON but you send Content-Type: text/html, the browser will try to render it as HTML — breaking mobile apps, causing parse errors in API clients, and causing security issues. |
| Location |
Used with 3xx redirect responses and 201 Created responses. Tells the client where to go next.
HTTP Response
# After creating a resource (201) — tell client where to find it
HTTP/1.1 201 Created
Location: /api/users/42
Content-Type: application/json
{"id": 42, "name": "Rahul"}
# Permanent redirect (301) — resource moved
HTTP/1.1 301 Moved Permanently
Location: https://api.newdomain.com/users/42
// Set Location header after resource creation
app.post('/api/users', authenticate, async (req, res) => {
const user = await User.create(req.body);
res
.status(201)
.location(`/api/users/${user._id}`) // tell client where new resource lives
.json({ data: user });
});
| Set-Cookie |
Sends a cookie from the server to the client. The browser stores it and automatically sends it back on every subsequent request to the same domain.
HTTP Response
HTTP/1.1 200 OK
Set-Cookie: sessionId=abc123xyz; HttpOnly; Secure; SameSite=Strict; Max-Age=3600; Path=/
| Attribute | What it does | Why it matters |
| HttpOnly | JavaScript cannot read this cookie | Prevents XSS attacks from stealing session cookies |
| Secure | Cookie only sent over HTTPS — never HTTP | Prevents cookie theft on insecure connections |
| SameSite=Strict | Cookie never sent in cross-site requests | Prevents CSRF attacks completely |
| SameSite=Lax | Sent in top-level navigations but not API calls | Balanced security — good default for most apps |
| SameSite=None | Sent in all cross-site requests (needs Secure) | Required for cross-site auth flows (OAuth, iframes) |
| Max-Age=N | Cookie expires after N seconds | Control session lifetime |
| Path=/ | Cookie sent for all paths on the domain | Scoping — use specific paths when needed |
| Domain=.example.com | Cookie shared across all subdomains | For multi-subdomain auth |
| 🔒 Always set HttpOnly + Secure + SameSite on session cookies. Missing HttpOnly allows XSS to steal tokens. Missing Secure sends cookies over HTTP. Missing SameSite allows CSRF attacks. All three together are your minimum baseline for session security. |
Part 3 — Security Headers
Security headers are response headers that instruct browsers to enforce security policies. They protect your users from a wide range of attacks — XSS, clickjacking, MIME sniffing, and more.
Most developers set these once in middleware and forget about them. But they matter enormously.
| Strict-Transport-Security (HSTS) |
Tells the browser: only ever connect to this domain over HTTPS — never HTTP. Once set, the browser refuses any HTTP connections to the domain for the duration of max-age.
HTTP Response
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
| Directive | Meaning |
| max-age=31536000 | Remember HTTPS-only for 1 year (31536000 seconds) |
| includeSubDomains | Apply rule to all subdomains too (api., admin., etc.) |
| preload | Submit domain to browser HSTS preload list — hardcoded in browsers |
| ⚠️ Do NOT set HSTS until you are 100% sure your entire domain serves HTTPS correctly — including all subdomains if using includeSubDomains. Once set, there is no easy way to undo it for users who already cached the header. Test thoroughly on staging first. |
| Content-Security-Policy (CSP) |
CSP tells the browser which sources are allowed to load scripts, styles, images, and other resources. It is the strongest defence against XSS (Cross-Site Scripting) attacks.
# Strict CSP — only load resources from your own domain
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.example.com; font-src 'self' https://fonts.googleapis.com; object-src 'none'; frame-ancestors 'none'
# What each directive means:
# default-src 'self' — default: only from same origin
# script-src 'self' — scripts: only from same origin (blocks inline scripts)
# style-src 'unsafe-inline' — allows inline styles (needed for many UI libs)
# img-src — images from self, data URIs, and your CDN
# font-src — fonts from self and Google Fonts
# object-src 'none' — block all plugins (Flash, etc.)
# frame-ancestors 'none'— nobody can embed this page in an iframe
| 💡 Start with Content-Security-Policy-Report-Only instead of Content-Security-Policy. This reports violations without blocking anything — so you can see what would break before enforcing. Switch to enforcement once you’ve fixed all violations. |
| Other Essential Security Headers |
| Header | Value | Protects Against |
| X-Frame-Options | DENY or SAMEORIGIN | Clickjacking — prevents embedding in iframes |
| X-Content-Type-Options | nosniff | MIME sniffing — browser must use declared Content-Type |
| Referrer-Policy | strict-origin-when-cross-origin | Leaking full URL in Referer header to other sites |
| Permissions-Policy | camera=(), microphone=() | Controls access to browser APIs (camera, mic, etc.) |
| Cross-Origin-Opener-Policy | same-origin | Isolates browsing context from cross-origin windows |
// Set ALL security headers in one middleware using helmet
const helmet = require('helmet');
app.use(helmet({
// HSTS
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
// X-Frame-Options: DENY
frameguard: { action: 'deny' },
// X-Content-Type-Options: nosniff
noSniff: true,
// Referrer-Policy
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
// Content-Security-Policy
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://cdn.example.com"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"]
}
}
}));
| 🚀 Install helmet in every Express project from day one. It sets 11 security headers automatically and is used by thousands of production Node.js applications. One line of middleware eliminates an entire class of web vulnerabilities. |
Part 4 — Caching Headers Deep Dive
Caching is one of the highest-leverage performance improvements in web backends. Getting headers right means free speed. Getting them wrong means stale data, broken deployments, and angry users.
The Full Caching Decision Flow
When a browser or CDN receives a response, it uses these headers to decide how to cache it:
| Step | Header checked | Decision |
| 1 | Cache-Control: no-store | Don’t cache. Stop. Always fetch fresh. |
| 2 | Cache-Control: no-cache | Cache it but revalidate before every use. |
| 3 | Cache-Control: max-age=N | Cache it. Fresh for N seconds from request time. |
| 4 | Cache-Control: s-maxage=N | For CDNs: use s-maxage instead of max-age. |
| 5 | ETag or Last-Modified present? | Use for conditional requests — check if still fresh. |
| 6 | Expires header present? | Old way — use max-age instead in new APIs. |
Conditional Requests — If-None-Match and If-Modified-Since
These request headers work together with ETag and Last-Modified response headers to implement conditional caching — the client asks ‘has this changed since I last got it?’
| Request Header | Works With | How It Works |
| If-None-Match | ETag | Send cached ETag. Server returns 304 if content hasn’t changed. |
| If-Modified-Since | Last-Modified | Send cached date. Server returns 304 if not modified since that date. |
// Full conditional caching with Last-Modified
app.get('/api/posts/:id', async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) return res.status(404).json({ error: 'Not found' });
const lastModified = post.updatedAt.toUTCString();
const clientDate = req.headers['if-modified-since'];
// check if client's cached version is still fresh
if (clientDate && new Date(clientDate) >= post.updatedAt) {
return res.status(304)
.set('Last-Modified', lastModified)
.end(); // 304 — no body
}
res
.status(200)
.set('Last-Modified', lastModified)
.set('Cache-Control', 'public, max-age=60, must-revalidate')
.json(post);
});
Caching Strategy by Endpoint Type
| Endpoint Type | Cache-Control Value | Reasoning |
| Static assets (versioned) | public, max-age=31536000, immutable | File won’t change — cache forever, skip revalidation |
| Public API data | public, max-age=300 | 5 min cache — good balance of freshness and speed |
| User-specific API data | private, max-age=60 | Only in browser — don’t cache in CDN |
| Frequently changing data | public, max-age=10, stale-while-revalidate=5 | Cache briefly — serve stale while fetching fresh |
| Sensitive / financial data | no-store | Never cache anywhere — always fresh from server |
| Auth tokens / session data | no-store | Never cache — tokens must always be fresh |
Part 5 — CORS Headers (Cross-Origin Resource Sharing)
CORS headers control which origins (domains) are allowed to make requests to your API from a browser. This is one of the most misunderstood topics in backend development.
Why CORS Exists
Browsers enforce the Same-Origin Policy — by default, JavaScript on https://frontend.com cannot make requests to https://api.example.com. CORS headers are the server’s way of saying ‘yes, I allow requests from that origin’.
| 🤔 CORS is a BROWSER security mechanism. It does not apply to server-to-server requests, curl, Postman, or mobile apps. If a curl request works but the browser gets a CORS error — it is not a network issue. It is a missing header issue. |
| CORS Header | Direction | Purpose |
| Access-Control-Allow-Origin | Response | Which origins are allowed. Use ‘*’ for public APIs, specific origin for auth. |
| Access-Control-Allow-Methods | Response | Which HTTP methods are allowed from this origin |
| Access-Control-Allow-Headers | Response | Which request headers the browser is allowed to send |
| Access-Control-Allow-Credentials | Response | Allow cookies and Authorization headers in cross-origin requests |
| Access-Control-Max-Age | Response | How long to cache the preflight response (seconds) |
| Access-Control-Expose-Headers | Response | Which response headers the browser JS can read |
const cors = require('cors');
// Option 1: Allow all origins (public API — no credentials)
app.use(cors());
// Option 2: Allow specific origin with credentials (auth API)
app.use(cors({
origin: 'https://myfrontend.com', // exact origin
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true, // allow cookies and Authorization header
maxAge: 86400 // cache preflight for 24 hours
}));
// Option 3: Dynamic origin check (multiple allowed frontends)
app.use(cors({
origin: (origin, callback) => {
const allowed = [
'https://app.example.com',
'https://admin.example.com',
'http://localhost:3000' // allow local dev
];
if (!origin || allowed.includes(origin)) {
callback(null, true); // allow
} else {
callback(new Error('CORS not allowed')); // block
}
},
credentials: true
}));
| ❌ Never use Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. This combination is actually forbidden by the CORS spec and browsers will block it. If you need credentials, you must specify the exact origin — not a wildcard. |
The Preflight Request (OPTIONS)
Before certain cross-origin requests, the browser automatically sends an OPTIONS request (preflight) to check if the actual request is allowed. This happens when:
- The method is anything other than GET, POST, or HEAD
- The Content-Type is anything other than text/plain, multipart/form-data, or application/x-www-form-urlencoded
- Custom headers are included (like Authorization)
HTTP Request
# Browser sends this automatically BEFORE the actual request
OPTIONS /api/users HTTP/1.1
Origin: https://myfrontend.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
HTTP Response
# Server must respond to the preflight
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myfrontend.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
| 💡 Set Access-Control-Max-Age to 86400 (24 hours). Without it, browsers send a preflight before EVERY request — doubling your API call count. With it cached, preflights happen only once per day per endpoint. |
Part 6 — Custom Headers and X- Headers
You can create your own headers for your API. Custom headers must be prefixed with X- (by convention, though modern specs no longer strictly require it). They are used for tracing, versioning, rate limiting, and internal communication.
| Custom Header | Common Use | Example Value |
| X-Request-ID | Unique ID per request for distributed tracing | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
| X-Rate-Limit-Limit | Max requests allowed per window | 100 |
| X-Rate-Limit-Remaining | Remaining requests in current window | 73 |
| X-Rate-Limit-Reset | Unix timestamp when window resets | 1711929600 |
| X-API-Version | Which API version handled this request | 2.1.0 |
| X-Response-Time | How long the server took to process (ms) | 45ms |
| X-Correlation-ID | ID to correlate logs across multiple services | req-abc123-svc-xyz789 |
// Middleware to add tracing and timing headers to every response
const { v4: uuidv4 } = require('uuid');
app.use((req, res, next) => {
const requestId = uuidv4();
const startTime = Date.now();
// attach to request for use in handlers and logs
req.requestId = requestId;
// add to response headers
res.set('X-Request-ID', requestId);
// log when response finishes
res.on('finish', () => {
const duration = Date.now() - startTime;
res.set('X-Response-Time', `${duration}ms`);
console.log({
requestId,
method: req.method,
path: req.path,
status: res.statusCode,
duration: `${duration}ms`
});
});
next();
});
| 🌍 X-Request-ID is non-negotiable in production. When a user reports a bug and you have their request ID, you can grep your logs across all services and find the exact request in seconds. Without it, debugging distributed systems is a nightmare. |
Part 7 — A Real Production HTTP Response
Let’s look at what a well-configured production API response looks like with all the right headers set:
HTTP Response
HTTP/2 200 OK
content-type: application/json; charset=utf-8
content-encoding: br
content-length: 1247
cache-control: private, max-age=300, must-revalidate
etag: "a3f4b2c1d0e9f8g7h6i5"
x-request-id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
x-response-time: 42ms
x-rate-limit-limit: 1000
x-rate-limit-remaining: 847
x-rate-limit-reset: 1711929600
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
access-control-allow-origin: https://app.example.com
vary: Accept-Encoding, Authorization
date: Tue, 01 Apr 2026 06:30:00 GMT
{...response body...}
Every single header here is doing a job:
- content-encoding: br — body is Brotli compressed (saves ~75% bandwidth)
- cache-control: private, max-age=300 — user-specific, cached in browser for 5 min
- etag — fingerprint for conditional requests
- x-request-id — distributed tracing
- x-rate-limit-* — client knows how many requests they have left
- strict-transport-security — HTTPS enforced for 1 year
- x-content-type-options: nosniff — prevents MIME type attacks
- x-frame-options: DENY — no iframe embedding
- access-control-allow-origin — CORS for specific frontend
- vary: Accept-Encoding — caches must store different versions by encoding
Interview Questions on HTTP Headers
| 🎯 Q: What headers would you set to properly cache a public API response for 5 minutes? |
Cache-Control: public, max-age=300. Additionally set ETag for conditional requests, Vary: Accept-Encoding if you support compression, and Content-Type for correct parsing. If using a CDN, also consider s-maxage=300 to differentiate browser vs CDN TTL.
| 🎯 Q: What is the difference between no-cache and no-store? |
no-store means never cache this response anywhere — always fetch fresh from the server. Use for highly sensitive data. no-cache means you can cache it, but always revalidate with the server before using the cached copy. The browser stores it and sends an If-None-Match or If-Modified-Since header — if the server says nothing changed (304), the cached version is used. no-cache is cheaper than no-store but ensures freshness.
| 🎯 Q: Why does my API work in Postman but fail in the browser with a CORS error? |
CORS is a browser security mechanism — it only applies to browser requests. Postman, curl, and server-to-server requests bypass CORS entirely. The browser CORS error means your server is not returning the correct Access-Control-Allow-Origin header. Fix: add cors() middleware in Express, set Access-Control-Allow-Origin to the frontend’s origin, and ensure OPTIONS preflight requests are handled.
| 🎯 Q: What security headers should every production API set? |
At minimum: Strict-Transport-Security (HTTPS enforcement), X-Content-Type-Options: nosniff, X-Frame-Options: DENY or SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin, Content-Security-Policy (even a basic one). In Node.js, install helmet — it sets all of these and more in a single middleware call.
| 🎯 Q: What is ETag and how does it reduce bandwidth? |
ETag is a hash/fingerprint of a resource’s content. On first request, the server sends the resource with an ETag. On subsequent requests, the client sends If-None-Match: <etag>. If the resource hasn’t changed, the server returns 304 Not Modified with NO body — just headers. A typical API response of 100KB becomes a 200-byte 304 response on cache hits — 99.8% bandwidth reduction.
Quick Reference — Headers Every Backend Developer Must Know
| Header | Direction | Set It When |
| Content-Type | Both | Always — on every request/response with a body |
| Authorization: Bearer | Request | Every authenticated API call |
| Cache-Control | Response | Every GET response — choose the right directive |
| ETag | Response | Read-heavy endpoints where data changes occasionally |
| Strict-Transport-Security | Response | Every HTTPS response — enforce HTTPS |
| X-Content-Type-Options: nosniff | Response | Every response — prevent MIME sniffing |
| X-Frame-Options: DENY | Response | Every response — prevent clickjacking |
| Content-Security-Policy | Response | Every HTML response — prevent XSS |
| Access-Control-Allow-Origin | Response | Any API called from a browser on a different domain |
| X-Request-ID | Response | Every response — enables distributed tracing |
Wrapping Up
HTTP headers are the nervous system of web communication. They control caching, authentication, security, compression, CORS, and tracing — all without touching your business logic.
The four things every backend developer should do right now:
- Install helmet in every Node.js project — instant security header coverage
- Set Cache-Control correctly on every GET endpoint — match the cache strategy to the data type
- Add X-Request-ID to every response — you will thank yourself when debugging production issues
- Set Access-Control-Allow-Origin carefully — never use wildcard with credentials
Once these habits are in place, your APIs will be faster, safer, and significantly easier to debug.
Got a question about a specific header or use case? Drop it in the comments — I reply to every one.