HTTP Headers Explained — The Complete Backend Developer Guide

SeriesTopicLevelRead Time
Web & HTTP Foundations — Part 3 of 12HTTP Headers — All Categories Deep DiveBeginner 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:

CategoryDirectionPurposeExamples
Request HeadersClient → ServerDescribe the request, carry auth, declare capabilitiesContent-Type, Authorization, Accept, User-Agent
Response HeadersServer → ClientDescribe the response, control caching and behaviourCache-Control, ETag, Content-Type, Set-Cookie
Representation HeadersBothDescribe the format and encoding of the bodyContent-Type, Content-Length, Content-Encoding
Security HeadersServer → ClientEnforce browser security policiesHSTS, 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.

ValueUse Case
application/jsonSending JSON data — used in almost all REST APIs
application/x-www-form-urlencodedHTML form submissions (default browser form POST)
multipart/form-dataFile uploads — allows binary data alongside text fields
text/plainPlain text body — rare in APIs
application/xmlXML 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.

SchemeFormatUse Case
BearerAuthorization: Bearer <token>JWT tokens — most common in modern REST APIs
BasicAuthorization: Basic <base64>Username:password encoded in Base64 — avoid in production
API KeyAuthorization: ApiKey <key>Simple API key auth — used by many third-party services
DigestAuthorization: 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
EncodingCompression RatioSpeedNotes
br (Brotli)Best — 20-26% better than gzipSlower to compressModern — supported in all current browsers
gzipGoodFastStandard — supported everywhere
deflateSimilar to gzipFastRarely used — gzip preferred
identityNo compressionFastestRaw 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
HeaderPurposeExample Value
HostWhich domain the request is for — required in HTTP/1.1api.example.com
OriginWhere the request originated — used in CORS checkshttps://myfrontend.com
RefererThe URL the user came fromhttps://google.com/search?q=…
CookieSend cookies to the server for session managementsessionId=abc123; userId=42
If-None-MatchSend cached ETag back — server returns 304 if unchangedW/”abc123xyz”
If-Modified-SinceSend cached date — server returns 304 if not modified sinceMon, 31 Mar 2026 00:00:00 GMT
X-Request-IDUnique ID for distributed tracing and debugginga1b2c3d4-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.

DirectiveMeaningUse When
no-storeNever cache this response anywhereSensitive data: user profiles, payment info, private data
no-cacheCache it but ALWAYS revalidate with server before usingData that changes frequently but caching is still useful
privateOnly the browser can cache — not CDNs or shared cachesUser-specific data that should not be shared
publicAnyone can cache — browser and CDNPublic data: blog posts, product listings, static content
max-age=NCache for N seconds from request timeWith ‘public’: static assets, with ‘private’: user sessions
s-maxage=NCache for N seconds in shared caches (CDN) onlyWhen CDN TTL should differ from browser TTL
must-revalidateMust check server when cache expires — no stale servingCritical data where stale responses are unacceptable
stale-while-revalidate=NServe stale while fetching fresh in backgroundNon-critical data where speed matters more than freshness
immutableContent will never change — skip revalidation entirelyVersioned 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.

ValueWhen to use
application/jsonJSON response body — most REST APIs
text/html; charset=utf-8HTML pages
text/plainPlain text responses
application/octet-streamBinary file download
image/png, image/jpegImage responses
application/pdfPDF file responses
text/csvCSV 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=/
AttributeWhat it doesWhy it matters
HttpOnlyJavaScript cannot read this cookiePrevents XSS attacks from stealing session cookies
SecureCookie only sent over HTTPS — never HTTPPrevents cookie theft on insecure connections
SameSite=StrictCookie never sent in cross-site requestsPrevents CSRF attacks completely
SameSite=LaxSent in top-level navigations but not API callsBalanced security — good default for most apps
SameSite=NoneSent in all cross-site requests (needs Secure)Required for cross-site auth flows (OAuth, iframes)
Max-Age=NCookie expires after N secondsControl session lifetime
Path=/Cookie sent for all paths on the domainScoping — use specific paths when needed
Domain=.example.comCookie shared across all subdomainsFor 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
DirectiveMeaning
max-age=31536000Remember HTTPS-only for 1 year (31536000 seconds)
includeSubDomainsApply rule to all subdomains too (api., admin., etc.)
preloadSubmit 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
HeaderValueProtects Against
X-Frame-OptionsDENY or SAMEORIGINClickjacking — prevents embedding in iframes
X-Content-Type-OptionsnosniffMIME sniffing — browser must use declared Content-Type
Referrer-Policystrict-origin-when-cross-originLeaking full URL in Referer header to other sites
Permissions-Policycamera=(), microphone=()Controls access to browser APIs (camera, mic, etc.)
Cross-Origin-Opener-Policysame-originIsolates 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:

StepHeader checkedDecision
1Cache-Control: no-storeDon’t cache. Stop. Always fetch fresh.
2Cache-Control: no-cacheCache it but revalidate before every use.
3Cache-Control: max-age=NCache it. Fresh for N seconds from request time.
4Cache-Control: s-maxage=NFor CDNs: use s-maxage instead of max-age.
5ETag or Last-Modified present?Use for conditional requests — check if still fresh.
6Expires 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 HeaderWorks WithHow It Works
If-None-MatchETagSend cached ETag. Server returns 304 if content hasn’t changed.
If-Modified-SinceLast-ModifiedSend 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 TypeCache-Control ValueReasoning
Static assets (versioned)public, max-age=31536000, immutableFile won’t change — cache forever, skip revalidation
Public API datapublic, max-age=3005 min cache — good balance of freshness and speed
User-specific API dataprivate, max-age=60Only in browser — don’t cache in CDN
Frequently changing datapublic, max-age=10, stale-while-revalidate=5Cache briefly — serve stale while fetching fresh
Sensitive / financial datano-storeNever cache anywhere — always fresh from server
Auth tokens / session datano-storeNever 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 HeaderDirectionPurpose
Access-Control-Allow-OriginResponseWhich origins are allowed. Use ‘*’ for public APIs, specific origin for auth.
Access-Control-Allow-MethodsResponseWhich HTTP methods are allowed from this origin
Access-Control-Allow-HeadersResponseWhich request headers the browser is allowed to send
Access-Control-Allow-CredentialsResponseAllow cookies and Authorization headers in cross-origin requests
Access-Control-Max-AgeResponseHow long to cache the preflight response (seconds)
Access-Control-Expose-HeadersResponseWhich 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 HeaderCommon UseExample Value
X-Request-IDUnique ID per request for distributed tracinga1b2c3d4-e5f6-7890-abcd-ef1234567890
X-Rate-Limit-LimitMax requests allowed per window100
X-Rate-Limit-RemainingRemaining requests in current window73
X-Rate-Limit-ResetUnix timestamp when window resets1711929600
X-API-VersionWhich API version handled this request2.1.0
X-Response-TimeHow long the server took to process (ms)45ms
X-Correlation-IDID to correlate logs across multiple servicesreq-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

HeaderDirectionSet It When
Content-TypeBothAlways — on every request/response with a body
Authorization: BearerRequestEvery authenticated API call
Cache-ControlResponseEvery GET response — choose the right directive
ETagResponseRead-heavy endpoints where data changes occasionally
Strict-Transport-SecurityResponseEvery HTTPS response — enforce HTTPS
X-Content-Type-Options: nosniffResponseEvery response — prevent MIME sniffing
X-Frame-Options: DENYResponseEvery response — prevent clickjacking
Content-Security-PolicyResponseEvery HTML response — prevent XSS
Access-Control-Allow-OriginResponseAny API called from a browser on a different domain
X-Request-IDResponseEvery 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.

Leave a Comment