REST API Conditional Requests: Mastering If-Modified-Since
A practical guide to HTTP If-Modified-Since for REST APIs: how it works, 304 responses, server/client examples, pitfalls, and best practices.
Image used for representation purposes only.
Overview
Conditional requests are one of the simplest, highest‑leverage optimizations you can add to a REST API. With the If-Modified-Since header, clients ask the server to send a representation only if it has changed after a given time. If it has not changed, the server replies 304 Not Modified and sends no body—saving bandwidth, CPU, and time while keeping caches fresh.
This article explains how If-Modified-Since works, how to implement it cleanly on the server, how to consume it from clients, and common pitfalls to avoid. We’ll also compare it to ETag/If-None-Match and show how to use both together for robust cache revalidation.
What is If-Modified-Since?
- Header: If-Modified-Since:
- Purpose: Revalidate a cached representation using a time validator instead of refetching the full body.
- Typical flow:
- Client GETs a resource and receives a Last-Modified header.
- On the next request, the client sends If-Modified-Since with that timestamp.
- Server compares the resource’s last modification time to the header.
- If not modified since that time → 304 Not Modified (no body). Otherwise → 200 OK with the full representation and a new Last-Modified.
HTTP date format must be IMF-fixdate (e.g., Tue, 15 Nov 1994 12:45:26 GMT). Always use GMT/UTC.
When to use it
- GET or HEAD requests for resources that change infrequently or whose changes are timestamped (e.g., articles, product pages, user profiles).
- List endpoints (e.g., /items?category=42) when you can compute a safe Last-Modified for the entire result set.
- CDN or reverse-proxy fronted APIs to reduce origin load via revalidation.
Avoid using If-Modified-Since on state-changing methods (PUT/POST/PATCH/DELETE). For those, use If-Unmodified-Since (preconditions) if you need edit concurrency control.
Server semantics in a nutshell
- If the resource’s last-modified time (LM) is less than or equal to the If-Modified-Since (IMS) time, return 304 Not Modified.
- Else, return 200 OK with the new representation and include an accurate Last-Modified header.
- If the IMS date is missing or invalid, ignore it and return 200 as usual.
- For GET/HEAD only; ignore IMS for other methods.
304 responses have no body. They should include headers relevant to caching (e.g., Date, Cache-Control, ETag if you also use it, Expires, Vary) and may include Last-Modified.
Client semantics in a nutshell
- Cache the server’s Last-Modified value after a successful 200 response.
- On the next request for the same resource and same parameters, send If-Modified-Since with that timestamp.
- On 304, use your cached body, but update any cache metadata based on the headers in the 304 response.
Example: cURL and raw HTTP
Initial fetch:
GET /articles/123 HTTP/1.1
Host: api.example.com
Accept: application/json
Server:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=0, must-revalidate
Last-Modified: Fri, 20 Mar 2026 10:15:00 GMT
{ "id": 123, "title": "Caching 101", "updatedAt": "2026-03-20T10:15:00Z" }
Revalidation:
curl -i \
-H "If-Modified-Since: Fri, 20 Mar 2026 10:15:00 GMT" \
https://api.example.com/articles/123
Server (unchanged):
HTTP/1.1 304 Not Modified
Date: Fri, 15 May 2026 17:40:12 GMT
Cache-Control: max-age=0, must-revalidate
Last-Modified: Fri, 20 Mar 2026 10:15:00 GMT
Implementation patterns
General recipe
- Determine a reliable last-modified instant for the representation:
- For a single entity: use its updated_at (not now()).
- For collections: use the max(updated_at) over the items returned by the current query and filters.
- Normalize to whole seconds (HTTP dates are second-precision).
- Compare LM and IMS:
- If LM <= IMS → 304.
- Else → 200 with body and Last-Modified.
- Always emit consistent caching headers (Cache-Control, Vary if needed, ETag if you use ETags too).
Node.js (Express)
const express = require('express');
const app = express();
// Helper: floor Date to whole seconds (HTTP-date precision)
function toHttpDate(d) {
const s = new Date(Math.floor(new Date(d).getTime() / 1000) * 1000);
return s.toUTCString();
}
app.get('/articles/:id', async (req, res) => {
const article = await db.articles.findById(req.params.id);
if (!article) return res.sendStatus(404);
const lastModified = toHttpDate(article.updatedAt);
res.set('Cache-Control', 'max-age=0, must-revalidate');
res.set('Last-Modified', lastModified);
const ims = req.get('If-Modified-Since');
if (ims) {
const imsDate = new Date(ims);
if (!isNaN(imsDate) && new Date(lastModified) <= imsDate) {
return res.status(304).end(); // no body
}
}
res.json({ id: article.id, title: article.title, updatedAt: article.updatedAt });
});
Python (FastAPI)
from datetime import datetime, timezone
from email.utils import format_datetime, parsedate_to_datetime
from fastapi import FastAPI, Request, Response, HTTPException
app = FastAPI()
@app.get("/articles/{id}")
async def get_article(id: int, request: Request):
article = await db.get_article(id)
if not article:
raise HTTPException(status_code=404)
lm = datetime.fromisoformat(article["updatedAt"]).astimezone(timezone.utc).replace(microsecond=0)
last_modified = format_datetime(lm) # IMF-fixdate (UTC/GMT)
ims_raw = request.headers.get("if-modified-since")
if ims_raw:
try:
ims = parsedate_to_datetime(ims_raw).astimezone(timezone.utc).replace(microsecond=0)
if lm <= ims:
return Response(status_code=304, headers={
"Cache-Control": "max-age=0, must-revalidate",
"Last-Modified": last_modified,
})
except Exception:
pass # ignore invalid dates
body = {"id": id, "title": article["title"], "updatedAt": article["updatedAt"]}
return Response(
content=json.dumps(body),
media_type="application/json",
headers={
"Cache-Control": "max-age=0, must-revalidate",
"Last-Modified": last_modified,
},
)
Go (net/http)
package main
import (
"encoding/json"
"net/http"
"time"
)
type Article struct {
ID int `json:"id"`
Title string `json:"title"`
UpdatedAt time.Time `json:"updatedAt"`
}
func articleHandler(w http.ResponseWriter, r *http.Request) {
art, ok := loadArticle(r) // your lookup
if !ok { http.NotFound(w, r); return }
lm := art.UpdatedAt.UTC().Truncate(time.Second)
w.Header().Set("Cache-Control", "max-age=0, must-revalidate")
w.Header().Set("Last-Modified", lm.Format(http.TimeFormat))
if ims := r.Header.Get("If-Modified-Since"); ims != "" {
if t, err := http.ParseTime(ims); err == nil {
if !lm.After(t.UTC()) { // lm <= ims
w.WriteHeader(http.StatusNotModified)
return
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(art)
}
Lists, filters, and pagination
For collection endpoints, compute Last-Modified from the exact result set, not the entire table:
- Use the maximum updated_at among returned items (after applying all filters).
- If the list is empty, choose a safe policy:
- Option A: Omit Last-Modified (client won’t send IMS next time).
- Option B: Use a stable timestamp for the query context (e.g., last index rebuild). Be conservative—never claim “not modified” if new items could appear.
- For paginated results, Last-Modified should reflect the page’s items. Clients must include identical query parameters when revalidating; caches key by the full URL, including query strings.
IMS vs ETag (and using both)
- If-Modified-Since uses time validation; it is coarse (second precision) and can fail if clocks skew.
- ETag/If-None-Match uses an opaque validator tied to the exact representation—often more precise and resilient.
- Best practice: send both Last-Modified and ETag. Clients can send both validators; servers must give precedence to If-None-Match (ETag) when both are present. If the ETag doesn’t match, return 200; otherwise, you may then consider IMS.
Example combined request:
GET /articles/123 HTTP/1.1
Host: api.example.com
If-None-Match: "w/\"a1b2c3\""
If-Modified-Since: Fri, 20 Mar 2026 10:15:00 GMT
Cache-Control, Vary, and auth
- Cache-Control: Pair revalidation with clear directives. For example, max-age=0, must-revalidate forces revalidation every time but still enables 304 savings.
- Vary: If the representation changes across headers (e.g., Accept, Accept-Language), set Vary accordingly so caches don’t mix variants.
- Authorization: Shared caches generally avoid caching authenticated responses unless explicitly allowed. You can still use IMS for private (client) caches to save bandwidth between the client and your origin.
Edge cases and pitfalls
- Clock skew: Clients and servers must compare using UTC/GMT. Rely on server-side resource times (database timestamps), not system.now() per request.
- Second precision: HTTP dates do not carry sub-second precision. Truncate LM and IMS to whole seconds to avoid false “modified” decisions.
- Dynamic generation: Don’t set Last-Modified to the request time. It should reflect the representation’s actual modification time.
- 304 body: Never send a message body with 304; some servers will add Content-Length: 0 automatically.
- Time parsing: Be lenient with minor date variations but fall back to 200 on parse errors.
- Ranges: If also handling Range requests, follow normal precedence rules; conditional range requests can return 206 or 304 depending on validators.
- Safety: For non-idempotent methods (POST, PATCH, DELETE), ignore IMS. If you need preconditions, use If-Unmodified-Since or If-Match.
Working with CDNs and reverse proxies
- IMS enables efficient origin revalidation. The edge cache forwards If-Modified-Since to the origin; a 304 lets the edge keep serving the cached object while refreshing metadata (TTL, headers).
- Include consistent Last-Modified and (ideally) ETag so intermediaries can choose the best validator.
- Tune Cache-Control: max-age and stale-while-revalidate can reduce tail latency while still enabling periodic revalidation.
Observability and testing
- Log conditional hits vs misses (304 vs 200). A rising 304 ratio indicates healthier caching behavior.
- In staging, simulate content updates and verify transitions: 200 → 304 (no change), then 200 after an edit.
- Capture headers in traces to debug validator mismatches.
Quick checklist
- Compute Last-Modified from real data, not request time.
- Truncate times to seconds and format as IMF-fixdate (GMT).
- For GET/HEAD, return 304 when LM <= IMS; otherwise 200 with body.
- Include Cache-Control and optionally ETag; If-None-Match takes precedence over IMS.
- For lists, base LM on the filtered result set.
- Never include a body with 304; do include relevant cache headers.
- Ignore IMS for state-changing methods; use preconditions instead if needed.
Conclusion
If-Modified-Since is a small header with outsized impact. Implemented correctly, it slashes bandwidth and CPU without changing your payload format or your URL structure. Pair it with ETags, consistent Cache-Control, and careful timestamp handling, and you’ll gain faster responses, happier clients, and lower bills—often in a single iteration.
Related Posts
Designing REST APIs with Partial Response Field Selection (fields, select, $select)
Design and implement REST API partial responses for speed and safety—syntax options, caching, security, and implementation patterns with examples.
REST API Content Negotiation: A Practical Guide
Design, implement, and test REST API content negotiation: headers, q-values, media types, versioning, caching, and error handling with practical examples.
Designing REST API Batch Operations: Patterns, Semantics, and Examples
Designing robust REST API batch operations: models, atomicity, idempotency, error handling, async jobs, limits, and examples.