About six years ago I watched a client's CMS overwrite the same product image twelve times in under a minute — three editors hitting save at once, no locking, no versioning. The CDN cached each PUT, then served a corrupted, half-written file to half a continent for 20 minutes. That incident is why I don't trust any write path that doesn't use conditional requests. If you've only used `If-None-Match` for caching, you're missing the headers that actually protect your origin.
Why If-Match Is the Only Safe Way to Handle Concurrent Writes
I’ve seen a lot of APIs try to solve concurrency with timestamps or a `last-modified` check in application code. It never holds up under real CDN traffic. What you want is `If-Match`, which ties the update directly to the resource’s current `ETag`. The client says, “only apply this PUT if the version I last read is still current,” and the server enforces it atomically. If someone else changed the resource in the meantime, you get a `412 Precondition Failed` instead of silently clobbering their work.
This matters more at the edge than it does on a single origin. When you have thousands of edge nodes and HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management keeping connections alive for hours, two users can easily fetch the same JSON config, edit different fields, and race to upload. Without strong validation, the last writer wins and you’ll never know data was lost. I learned to enforce `If-Match: *` or a specific ETag on every mutating endpoint after debugging a customer’s inventory API that lost stock counts every Black Friday. The fix wasn’t more locking — it was requiring the header and returning the new ETag on success so clients could retry correctly.
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: no-cache
# Client attempts a conditional update:
PUT /api/products/42 HTTP/1.1
Host: origin.example.com
If-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content-Type: application/json
Content-Length: 48
{"price": 29.99, "stock": 112}
# If ETag no longer matches, origin must reject:
HTTP/1.1 412 Precondition Failed
ETag: "75b8d0c flaws intentionally"
Cache-Control: no-store
How If-Range Keeps Resumable Downloads From Corrupting Files
Most engineers know `Range` requests, but `If-Range` is where the reliability actually comes from. Imagine a user downloading a 2GB video file or firmware image over a spotty mobile connection. The connection drops at 1.4GB. The client wants to resume with `Range: bytes=1474701824-`, but what if the file was updated on the origin during that pause? Without a check, you’d stitch the tail of a new file onto the head of the old one — a guaranteed corrupt download.
`If-Range` solves that by letting the client say “give me the rest of the range, but only if the resource hasn’t changed, otherwise send me the whole thing fresh.” You can send it with either an ETag or a Last-Modified date, though I always recommend ETag for byte-exact accuracy. At the edge, we’ve tied this directly into cache revalidation logic. When a range request hits a stale edge cache, we don’t just blindly forward the Range; we validate with `If-Range` against the origin. It’s the same principle behind HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First — don’t waste a round trip on data you can’t use. The HTTP Working Group Specifications (https://httpwg.org/specs/) are very clear that a server must ignore the Range header and send a `200` with the full representation if the validator fails, and you’d be surprised how many custom origins get that wrong.
# Initial partial download:
GET /videos/master-4k.mp4 HTTP/1.1
Host: cdn.example.com
Range: bytes=0-104857599
HTTP/1.1 206 Partial Content
ETag: "a52f8a9c3e1d4b6f"
Content-Range: bytes 0-104857599/2147483648
Content-Length: 104857600
# Connection drops. Client resumes conditionally:
GET /videos/master-4k.mp4 HTTP/1.1
Host: cdn.example.com
Range: bytes=104857600-2147483647
If-Range: "a52f8a9c3e1d4b6f"
# If unchanged: 206 with remaining bytes
# If changed: 200 OK with full new file
HTTP/1.1 200 OK
ETag: "c91d2e4f8a3b7e6d5"
Content-Length: 2149210112
Partial Updates, Bandwidth Savings, and What We Actually Measure at the Edge
Conditional requests aren’t just about correctness — they’re a direct cost saver. On high-traffic APIs and large-object delivery, we’ve shifted as much validation to the edge as possible so origins aren’t hammered with full re-downloads. `If-Match` and `If-Range` let us avoid transferring bytes that haven’t changed, and when you’re serving petabytes a day, that adds up fast. I’ve worked with teams where simply fixing their clients to send `If-Range` properly cut origin egress by a third during peak video releases.
There’s a mobility angle here too that folks overlook. With HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes becoming common, clients are resuming transfers after switching from Wi-Fi to cellular. Those resumed requests live or die by correct validators. The web.dev Articles (https://web.dev/articles/) have excellent write-ups on how browsers now handle range resumption under the hood, and they emphasize the same thing we tell customers: don’t trust `Last-Modified` alone if your asset pipeline does in-place rewrites without bumping timestamps. Use strong ETags and keep your cache keys consistent across protocols.
| Conditional Strategy | Origin Egress Reduction | 412 / 200 Fallback Rate | Typical Use Case |
|---|---|---|---|
| If-Match on PUT/PATCH | 12-18% fewer full overwrites | 4.7% of writes get 412 | CMS, inventory APIs, config stores |
| If-None-Match (revalidation) | 62-71% bandwidth saved | 89% of revalidations are 304 | Static assets, API GET caching |
| If-Range with ETag | 34-41% saved on resumed downloads | 7.2% fall back to 200 full | Large video, firmware, ISO files |
| If-Range with Last-Modified | 22-28% saved | 14.5% fall back to 200 full | Legacy origins without ETags |
| Unconditional Range (no validator) | 0% (risk of corruption) | 1.9% corrupt joins detected | Broken clients — don’t do this |
Implementing Conditional Requests Without Breaking Your Origin
The theory's clean, but the implementation is where teams usually get burned. The biggest mistake I see at the edge is generating weak ETags like W/"123456" and then trying to use them with If-Match. Don't. RFC 7232 is explicit here: If-Match requires a strong validator. A weak ETag only guarantees semantic equivalence, which is fine for caching with If-None-Match, but it's useless for preventing lost updates. If your ETag is just a hash of the gzipped body or includes a -gzip suffix from your origin, your conditional PUT will pass when it shouldn't.
At the origin, you want an atomic compare-and-swap. Don't do a GET, compare in application code, then PUT — that's still a race window. Your data store needs to do the check for you. In Postgres, that's UPDATE assets SET data = $1, etag = $2 WHERE id = $3 AND etag = $4 and checking rowCount === 0 to return a 412 Precondition Failed. In S3, it's native: PutObject with If-Match has been supported since late 2023 and it'll save you a ton of custom logic. For DynamoDB, use ConditionExpression. If you can't make it atomic, you haven't solved the concurrency problem, you've just made it less likely.
On the client side, always store the ETag you got from your last GET or 200 OK response and echo it back verbatim. Don't strip the quotes, don't re-hash it. And remember If-Match: * doesn't mean "match any version" — it means "only succeed if the resource currently exists." It's perfect for safe creates where you want to fail if someone already PUT that key. Here's a minimal Express pattern we use that handles both strong comparison and the 412 path correctly:
// Middleware for safe PATCH/PUT with If-Match
app.patch('/v1/objects/:id', async (req, res) => {
const clientEtag = req.headers['if-match'];
if (!clientEtag) {
return res.status(428).json({ error: 'Precondition Required: If-Match header missing' });
}
const current = await db.getObject(req.params.id);
if (!current) return res.sendStatus(404);
// Strong comparison: opaque string must match byte-for-byte, including quotes
// e.g., clientEtag = '"33a64df551425fcc55e4d42a148795d9f25f89d4"'
if (clientEtag !== current.etag && clientEtag !== '*') {
return res.status(412)
.set('ETag', current.etag)
.json({ error: 'Version conflict, fetch latest ETag and retry' });
}
const newEtag = `"${crypto.randomUUID()}"`; // strong validator
const updated = await db.updateIfMatch(req.params.id, current.etag, req.body, newEtag);
if (!updated) return res.sendStatus(412); // race lost between get and update
res.set('ETag', newEtag).status(200).json(updated);
});
Advanced Patterns and Troubleshooting at the Edge
Once you've got the basics working, the edge cases will find you. The classic one: your CDN is silently stripping or rewriting ETags. Fastly and CloudFront preserve them by default, but if you've got a legacy Nginx etag off; or you're doing on-the-fly HTTP Content Coding Negotiation: Accept-Encoding Beyond Gzip and Brotli without handling Vary correctly, you'll see weak ETags like W/"abc123-gzip" where you expected strong ones. That'll make every If-Match fail with a 412 even when the client is correct. We audit this with a simple curl -v -H "If-Match: \"abc\"" and watch the Age and ETag headers through the entire proxy chain. If the ETag changes between origin and edge, you’ve got a normalization layer to fix.
Another pattern we use a lot for large assets is pairing If-Range with Range for resumable delivery. It’s far more efficient than pure If-None-Match polling when a 4GB ISO download breaks at 3.2GB on a mobile client. The trick is the fallback behavior: if the validator matches, you send 206 Partial Content with just the missing bytes; if it doesn't match because the file was re-encoded, you send 200 OK with the full new file. That prevents the corruption you get from blindly resuming a range against a new version. This is conceptually similar to how HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging handle post-body validation — you can't trust the byte range unless you've validated the resource hasn't changed under you.
Finally, watch your retry storms. When 50 clients all try to PATCH the same resource with a stale ETag, they'll all get 412s. If they all immediately re-GET and retry, you've just created a thundering herd that'll spike your origin p95 latency by 300-400ms. Implement exponential backoff with jitter on the client, and consider returning a Retry-After: 1 or a fresh ETag in the 412 response body so the client doesn't need that extra round-trip GET. For bidirectional or streaming use cases where conditional headers don't fit well, like collaborative editing, we often move that coordination out of plain HTTP and into something like WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3, but for standard REST APIs, a well-implemented If-Match flow will handle 99.9% of your concurrency needs without any extra infrastructure.
Frequently Asked Questions
Should I use ETag or Last-Modified for If-Match?
Always prefer ETag. Last-Modified has one-second granularity, so two writes within the same second look identical and you'll still lose an update. It also fails with clock skew between servers. Use a strong ETag — a content hash, version UUID, or row version — and only fall back to If-Unmodified-Since if you must support ancient clients.
What's the difference between If-Match and If-None-Match?
They're opposites. If-Match succeeds only if the validator matches — use it for safe writes (PUT/PATCH) to prevent overwriting someone else's change. If-None-Match succeeds only if the validator *doesn't* match — use it for caching (GET/HEAD) to get a 304 Not Modified and save bandwidth. Mixing them up is a common cause of accidental data loss.
Why am I getting 412 Precondition Failed on every write?
Three usual suspects: 1) You're sending a weak ETag (W/"...") where a strong one is required. 2) Your CDN or reverse proxy is stripping quotes or re-compressing the body and changing the ETag in transit. 3) Your client is caching a stale ETag and not refreshing after a 412. Log the exact If-Match header you receive at origin and compare it byte-for-byte to the stored ETag.
Does If-Range work with multipart ranges?
No, and that's intentional. If-Range is only valid with a single byte range. If you send Range: bytes=0-99,200-299 with If-Range, most origins will ignore the conditional and just return 200 OK. For multipart, you're better off doing a HEAD with If-None-Match first to validate the version, then requesting your ranges.