You know that feeling. Two tabs. Same note. You save in one, then save in the other, and the first version is just gone. Last write wins. That is the default. It feels fine until it is your sentence that vanished.
You can watch it happen on a laptop. Two terminals. No cloud. Copy this into if-match-server.mjs and run node if-match-server.mjs.
import http from "node:http";
let note = { text: "hello" };
let etag = "1";
http.createServer((req, res) => {
const reply = (status, body) => {
const json = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json",
ETag: `"${etag}"`,
"Content-Length": Buffer.byteLength(json),
});
res.end(json);
};
if (req.url !== "/note") return reply(404, { error: "not found" });
if (req.method === "GET") return reply(200, { text: note.text, etag });
if (req.method !== "PUT") return reply(405, { error: "method not allowed" });
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
const ifMatch = req.headers["if-match"];
if (ifMatch && ifMatch !== `"${etag}"`) {
return reply(412, { error: "Precondition Failed" });
}
const body = JSON.parse(raw || "{}");
note = { text: body.text ?? note.text };
etag = String(Number(etag) + 1);
reply(200, { text: note.text, etag });
});
}).listen(3456, "127.0.0.1", () => {
console.log("http://127.0.0.1:3456/note");
});
One note, at /note. A GET hands you the JSON and an ETag. An ETag is just the version token on that response. A PUT with no extra header overwrites. That overwrite is the whole problem.
I fetched it first.
curl -i http://127.0.0.1:3456/note
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "1"
Content-Length: 27
{"text":"hello","etag":"1"}
Call this tab one. It still has hello and ETag "1". You start rewriting. You have not saved yet.
Meanwhile tab two hits save. No extra header. Just a PUT.
curl -i -X PUT http://127.0.0.1:3456/note \
-H 'Content-Type: application/json' \
-d '{"text":"from tab two"}'
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34
{"text":"from tab two","etag":"2"}
GET it again and hello is gone.
curl -i http://127.0.0.1:3456/note
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34
{"text":"from tab two","etag":"2"}
Tab one still shows hello on screen. The server does not. If tab one saves now, it will wipe tab two the same way. That is the feeling. You thought you were editing. You were erasing.
Tab one still has ETag "1" in its head. Send that token with the PUT. The header is called If-Match. You are saying: only write if this is still the version I saw.
curl -i -X PUT http://127.0.0.1:3456/note \
-H 'Content-Type: application/json' \
-H 'If-Match: "1"' \
-d '{"text":"from tab one"}'
I ran that. This came back:
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "2"
Content-Length: 31
{"error":"Precondition Failed"}
GET it. Still from tab two. Your stale save did not land.
curl -i http://127.0.0.1:3456/note
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34
{"text":"from tab two","etag":"2"}
That 412 is the point. The server refused you because the note had moved on. Now send the ETag the last GET actually returned.
curl -i -X PUT http://127.0.0.1:3456/note \
-H 'Content-Type: application/json' \
-H 'If-Match: "2"' \
-d '{"text":"from tab two, edited"}'
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "3"
Content-Length: 42
{"text":"from tab two, edited","etag":"3"}
This time it wrote. The version became "3". So the rule, now that you have seen both: If-Match means only do this if the resource still matches this ETag. Comparison is strong, byte-for-byte. A weak tag like W/"1" never matches it. And this is not If-None-Match. That one is the cache shortcut that returns 304. Do not mix them.
Leave the header off and last write wins. Send a stale one and you get 412, and the body stays put. Send the current one and you get 200, and the version moves.
You GET, then you PUT with that ETag. If someone else wrote in between, you see 412 instead of a silent overwrite.
Here is the check I ran. I replayed If-Match: "2" after the ETag was already "3".
curl -i -X PUT http://127.0.0.1:3456/note \
-H 'Content-Type: application/json' \
-H 'If-Match: "2"' \
-d '{"text":"should not land"}'
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "3"
Content-Length: 31
{"error":"Precondition Failed"}
Then GET:
curl -i http://127.0.0.1:3456/note
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "3"
Content-Length: 42
{"text":"from tab two, edited","etag":"3"}
should not land is not in the body. The old token did not rewind the note. If your server is already at "3", stop it and run node if-match-server.mjs again. GET. PUT with the current ETag. Then replay the previous one. You should see 412. The text should stay on the current sentence.
After that first 412, is the note still from tab two?
Originally on The Developer Codes.
Have you ever lost work to a silent overwrite? How do you handle concurrent edits — ETags, versioning, something else? Genuinely curious how others approach this.
I write about building, writing, and becoming — subscribe: https://robertpaschal.substack.com
Top comments (3)
good
good
good