304 Not Modified
What is HTTP 304 Not Modified?
When you visit a website, your computer saves some files (like pictures and the ...
Explain Like I’m 3
You already have the toy you’re asking for! The person looks and says ‘You already have that exact toy in your toy box - go play with the one you have!’ You don’t need a new one because the one you have is perfect!
Example: You ask ‘Can I see that picture?’ Someone checks and says ‘You already have that same picture! Look at the one you have!’ So you look at your copy instead.
Explain Like I’m 5
When you visit a website, your computer saves some files (like pictures and the way the page looks) to load the site faster next time. When you come back to that website, your computer asks ‘Did anything change since I was here?’ The website checks and if nothing changed, it says ‘304 Not Modified - nope, everything is the same! Just use what you already saved!’ So your computer uses the files it already has instead of downloading them all over again. This makes the website load super fast! It’s like checking if your friend changed their outfit - if they’re wearing the same clothes, you don’t need to look at them again to remember what they look like.
Example: You visit your favorite game website. Your computer saved the logo image last time. When you visit again, it asks ‘Did the logo change?’ The server says ‘304 - nope, same logo!’ So your computer shows you the logo it already has instead of downloading it again.
Jr. Developer
HTTP 304 Not Modified is a conditional response status indicating the client’s cached version of a resource is still current and hasn’t changed. When a client makes a conditional request using If-None-Match (with ETag) or If-Modified-Since (with Last-Modified timestamp), the server compares values. If they match (resource unchanged), it responds with 304 instead of 200 + full body, saving bandwidth. The 304 response has no body - just headers like ETag, Cache-Control, Vary. The client reuses its cached version. This is fundamental to HTTP caching and dramatically improves performance: images, CSS, JS, and API responses can return 304, saving megabytes per page load. Always implement ETags or Last-Modified headers on cacheable resources. Clients automatically send conditional requests when they have cached versions with these validators. 304 is not a redirect in the traditional sense - it’s revalidation telling the client ‘your cache is fresh’.
Example: Client requests /logo.png with If-None-Match: “abc123” (the ETag from last time). Server checks current /logo.png ETag. Still “abc123”, so responds 304 Not Modified with no body. Client uses cached logo. Bandwidth saved: ~50KB. Page loads instantly.
Code Example
// Express.js implementing 304 with ETagsconst express = require('express');const crypto = require('crypto');const app = express();
// Generate ETag for contentfunction generateETag(content) { return crypto.createHash('md5') .update(content) .digest('hex');}
app.get('/api/data', async (req, res) => { const data = await db.getData(); const content = JSON.stringify(data);
// Generate ETag for current version const etag = `"${generateETag(content)}"`;
// Check client's ETag const clientETag = req.headers['if-none-match'];
if (clientETag === etag) { // Resource unchanged - return 304 return res.status(304) .set('ETag', etag) .set('Cache-Control', 'public, max-age=300') .end(); // No body! }
// Resource changed or first request - return 200 with data res.status(200) .set('ETag', etag) .set('Cache-Control', 'public, max-age=300') .json(data);});
// Last-Modified approachapp.get('/articles/:id', async (req, res) => { const article = await db.getArticle(req.params.id);
if (!article) { return res.status(404).json({ error: 'Not found' }); }
const lastModified = article.updated_at.toUTCString(); const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince === lastModified) { // Not modified since client's version return res.status(304) .set('Last-Modified', lastModified) .set('Cache-Control', 'public, max-age=600') .end(); }
// Send updated article res.status(200) .set('Last-Modified', lastModified) .set('Cache-Control', 'public, max-age=600') .json(article);});Crash Course
304 Not Modified, defined in RFC 9110 Section 15.4.5 (obsoleting RFC 7232), indicates that a conditional GET or HEAD request has been received and would have resulted in 200 OK except the condition evaluated to false. Per RFC 9110, the server MUST NOT generate a payload in a 304 response - it’s always terminated by the first empty line after headers. The server MUST send Cache-Control, Content-Location, Date, ETag, Expires, and Vary headers that would have been sent in a 200 response. Conditional requests use If-None-Match (comparing ETags) or If-Modified-Since (comparing timestamps). ETags are preferred: they’re byte-level validators, while Last-Modified is second-granularity and unreliable for rapidly changing resources. Strong ETags (“abc123”) indicate byte-for-byte equality; weak ETags (W/”abc123”) indicate semantic equivalence. 304 saves bandwidth dramatically - client sends ~200 byte request, gets ~200 byte 304 response instead of potentially megabytes of data. Critical for performance: static assets (images, CSS, JS), API responses, and dynamic HTML all benefit. CDNs leverage 304 for cache revalidation: edge cache expired, edge asks origin with If-None-Match, origin returns 304, edge serves stale copy (still fresh). Without 304, every cache expiry requires full re-download.
Example: CDN edge server cached /app.js (1MB) with ETag “v123” and max-age=3600. After 1 hour, max-age expires. User requests /app.js. Edge validates with origin: GET /app.js, If-None-Match: “v123”. Origin checks: current app.js still has ETag “v123”, returns 304 Not Modified with Cache-Control: max-age=3600. Edge updates freshness, serves cached 1MB /app.js to user. Bandwidth between edge and origin: ~400 bytes instead of 1MB. User gets instant response.
Code Example
// Production-grade conditional requests with ETagsconst express = require('express');const crypto = require('crypto');const app = express();
// Middleware to add ETag supportfunction etagMiddleware(req, res, next) { const originalJson = res.json.bind(res);
res.json = function(data) { const content = JSON.stringify(data); const etag = `"${crypto.createHash('md5').update(content).digest('hex')}"`;
res.set('ETag', etag);
// Check If-None-Match const clientETag = req.headers['if-none-match']; if (clientETag === etag) { return res.status(304).end(); }
originalJson(data); };
next();}
app.use(etagMiddleware);
app.get('/api/products', async (req, res) => { const products = await db.getProducts();
// ETag middleware handles 304 automatically res.set('Cache-Control', 'public, max-age=300'); res.json(products);});
// Manual ETag implementation with strong vs weakapp.get('/api/user/:id', async (req, res) => { const user = await db.getUser(req.params.id);
if (!user) { return res.status(404).json({ error: 'User not found' }); }
// Strong ETag based on content hash const userJson = JSON.stringify({ id: user.id, name: user.name, email: user.email }); const strongETag = `"${crypto.createHash('sha256').update(userJson).digest('hex').substring(0, 16)}"`;
// Check conditional request const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === strongETag) { return res.status(304) .set('ETag', strongETag) .set('Cache-Control', 'private, max-age=60') .set('Vary', 'Accept-Encoding') .end(); }
res.status(200) .set('ETag', strongETag) .set('Cache-Control', 'private, max-age=60') .set('Vary', 'Accept-Encoding') .json({ id: user.id, name: user.name, email: user.email });});
// Combined ETag + Last-Modifiedapp.get('/blog/:slug', async (req, res) => { const post = await db.getBlogPost(req.params.slug);
if (!post) { return res.status(404).send('Not found'); }
const lastModified = post.updated_at.toUTCString(); const etag = `"${post.id}-${post.updated_at.getTime()}"`;
// Check both validators (client may send one or both) const ifNoneMatch = req.headers['if-none-match']; const ifModifiedSince = req.headers['if-modified-since'];
// If either validator matches, return 304 if (ifNoneMatch === etag || ifModifiedSince === lastModified) { return res.status...Deep Dive
The 304 Not Modified status code, specified in RFC 9110 Section 15.4.5, indicates that a conditional request has been received and would have resulted in a 200 OK response if the condition(s) had not evaluated to false. Per RFC 9110, the server MUST NOT send a message body in a 304 response, and the response is always terminated by the first empty line after the header fields. The server generating a 304 response MUST generate any of the following header fields that would have been sent in a 200 OK response to the same request: Cache-Control, Content-Location, Date, ETag, Expires, and Vary. The 304 status enables efficient cache revalidation by allowing origin servers to inform clients and caches that their stored representations are still fresh without retransmitting the entire representation.
Technical Details
Conditional request mechanisms defined in RFC 9110 Part 4 include validators (ETags and Last-Modified timestamps) and preconditions (If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since, If-Range). For GET and HEAD, If-None-Match compares client’s ETag(s) with current resource ETag - if any match, return 304. If-Modified-Since compares client’s timestamp with resource’s Last-Modified - if resource not modified after that time, return 304. Per RFC 9110, If-None-Match takes precedence over If-Modified-Since when both are present. Multiple ETags can be sent in If-None-Match (comma-separated list) - 304 if any match. The special value ”*” in If-None-Match matches any ETag.\n\nStrong vs weak validators: Strong ETags (“abc123”) guarantee byte-for-byte equivalence - used when exact content matching is required (Range requests, If-Match). Weak ETags (W/”abc123”) indicate semantic equivalence but not byte-for-byte - allow for equivalent representations (e.g., gzipped vs uncompressed). For If-None-Match, weak comparison is used: weak ETags can match strong or other weak ETags if they have the same opaque value. Servers SHOULD use strong ETags for dynamic content that changes frequently, weak ETags for content where minor differences are acceptable (e.g., different compression, minification).\n\nCaching architecture with 304: Browser caches resource with ETag and max-age. Within max-age, serve from cache without server contact (fresh). After max-age expires (stale), browser sends conditional request with If-None-Match. If 304, browser updates freshness lifetime and serves cached content (revalidation). If 200, replace cached content. This “stale-while-revalidate” pattern maximizes performance while ensuring correctness. CDN architecture: Edge cache expires, edge validates with origin using If-None-Match, origin returns 304, edge serves stale content to users and updates freshness. Without 304, origin must send full content on every cache expiry.\n\nPerformance impact quantified: 1MB JavaScript bundle requested 10,000 times/day with 1-hour max-age means ~240 revalidations/day. With 304, each revalidation transfers ~500 bytes (request + response headers) instead of 1MB. Savings: 1MB - 500B ≈ 1MB * 240 = 240MB/day per resource. For sites with dozens of static assets and thousands of users, bandwidth savings reach gigabytes per day, translating to faster load times and reduced CDN costs.\n\nETag generation strategies: Content-based (MD5/SHA hash of response body) - accurate but computationally expensive for large responses; Version-based (resource ID + updated_at timestamp) - fast but requires database schema support; File-based (inode + mtime + size) - used by web servers like nginx/Apache for static files; Custom application logic (semantic versioning, Git commit hash). Trade-offs: Strong ETags enable more aggressive caching but are expensive to compute; weak ETags are cheaper but less precise. For high-traffic APIs, version-based ETags (e…
Code Example
// Enterprise-grade 304 implementation with advanced patterns\nconst express = require('express');\nconst crypto = require('crypto');\nconst { promisify } = require('util');\nconst app = express();\n\n// ETag generation strategies\nclass ETagGenerator {\n // Content-based (strong) - accurate but expensive\n static contentBased(content, algorithm = 'sha256') {\n const hash = crypto.createHash(algorithm)\n .update(typeof content === 'string' ? content : JSON.stringify(content))\n .digest('hex')\n .substring(0, 16); // Truncate for brevity\n return \`\\\"${hash}\\\"`; // Strong ETag\n }\n \n // Version-based (strong) - fast, requires version tracking\n static versionBased(resourceId, version) {\n return \`\\\"${resourceId}-${version}\\\"`; // Strong ETag\n }\n \n // Weak ETag - semantic equivalence\n static weak(content) {\n const hash = crypto.createHash('md5')\n .update(content)\n .digest('hex')\n .substring(0, 8);\n return \`W/\\\"${hash}\\\"`; // Weak ETag\n }\n}\n\n// Advanced conditional request middleware\nfunction conditionalRequest(options = {}) {\n const {\n useStrongETag = true,\n useWeakETag = false,\n useLastModified = true,\n etagAlgorithm = 'sha256'\n } = options;\n \n return (req, res, next) => {\n const originalJson = res.json.bind(res);\n const originalSend = res.send.bind(res);\n \n // Override json method\n res.json = function(data) {\n const content = JSON.stringify(data);\n handleConditional(res, req, content, data);\n \n if (res.headersSent) return; // Already sent 304\n originalJson(data);\n };\n \n // Override send method\n res.send = function(body) {\n handleConditional(res, req, body);\n \n if (res.headersSent) return; // Already sent 304\n originalSend(body);\n };\n \n function handleConditional(res, req, content, data = null) {\n let etag = null;\n let lastModified = null;\n \n // Generate ETag\n if (useStrongETag) {\n etag = ETagGenerator.contentBased(content, etagAlgorithm);\n } else if (useWeakETag) {\n etag = ETagGenerator.weak(content);\n }\n \n // Get Last-Modified from data if available\n if (useLastModified && data?.updated_at) {\n lastModified = new Date(data.updated_at).toUTCString();\n }\n \n // Set validators\n if (etag) res.set('ETag', etag);\n if (lastModified) res.set('Last-Modified', lastModified);\n \n // Check conditional request\n const ifNoneMatch = req.headers['if-none-match'];\n const ifModifiedSince = req.headers['if-modified-since'];\n \n // If-None-Match takes precedence\n if (ifNoneMatch && etag) {\n // Handle multiple ETags (comma-separated)\n const clientETags = ifNoneMatch.split(',').map(t => t.trim());\n \n // Weak comparison: W/\"abc\" matches \"abc\" or W/\"abc\"\n const match...Frequently Asked Questions
What's the difference between If-None-Match (ETag) and If-Modified-Since (Last-Modified)?
ETags are content-based validators that detect any change, even if timestamps are identical. They're byte-level precise and work for rapidly changing resources. Last-Modified uses timestamps with 1-second granularity, which can miss rapid updates. ETags are preferred and take precedence when both are present. Use ETags for APIs and dynamic content; Last-Modified works for static files where modification time is reliable.
Does 304 Not Modified include a response body?
No, 304 responses MUST NOT include a message body per RFC 9110. The response is terminated by the first empty line after headers. The client reuses its cached version of the resource. Sending a body with 304 violates the spec and wastes bandwidth - the whole point of 304 is to avoid transmitting the body.
How much bandwidth does 304 save?
Significant savings: a 1MB image with 304 response uses ~500 bytes (request + response headers) instead of 1MB - 99.95% reduction. For sites with many static assets and frequent visitors, this translates to gigabytes saved daily. Example: 10 resources × 100KB each × 1,000 users × 50% cache hit rate = 500MB saved vs. serving full responses.
Should I use strong ETags or weak ETags?
Use strong ETags ("abc123") for dynamic content where byte-for-byte equality matters (APIs, JSON responses, HTML). Use weak ETags (W/"abc123") when minor differences are acceptable (compressed vs uncompressed, minified vs non-minified). Strong ETags enable more aggressive caching but are computationally expensive. For high-traffic APIs, version-based strong ETags ("resource-id-version") offer the best performance.
How do CDNs use 304 Not Modified?
CDNs leverage 304 for cache revalidation: when cached content expires, the edge server validates with the origin using If-None-Match. If origin returns 304, the edge serves its stale copy to users and updates the freshness lifetime. This reduces origin bandwidth dramatically - instead of transferring full files on every cache expiry, only ~500 byte 304 responses are needed.
Common Causes
- Client requesting cached resource that hasn’t changed since last fetch
- Browser validating expired cache entry with If-None-Match or If-Modified-Since
- CDN edge server revalidating cached content with origin server
- API client checking if resource version has changed before processing
- Web browser checking if static assets (CSS, JS, images) need re-downloading
- Mobile app checking if API response has changed to minimize data usage
- Polling endpoint checking if new data is available without downloading full payload
- RSS reader checking if feed has new entries without re-downloading entire feed
Implementation Guidance
- Always implement ETags for dynamic content (APIs, HTML) for accurate validation
- Use Last-Modified headers for static files where modification time is reliable
- Prefer strong ETags for APIs, weak ETags only when minor differences are acceptable
- Set Cache-Control: must-revalidate to force validation after cache expiry
- Include Vary header to indicate which request headers affect the response
- For high-traffic APIs, use version-based ETags (fast) instead of content-hash ETags
- Ensure 304 responses include Cache-Control, ETag, and Vary headers from 200
- Never send a body with 304 - violates RFC and wastes bandwidth
- Use constant-time ETag comparison to prevent timing attacks on sensitive resources
- Combine with stale-while-revalidate for optimal performance and freshness