DEV Community

Cover image for If-Match: two curls, one 412
Odinaka Robert Nnamani
Odinaka Robert Nnamani

Posted on Originally published at thedevelopercodes.substack.com

If-Match: two curls, one 412

Two tabs save the same note. Last write wins. The first tab's words vanish.

Two terminals on your laptop are two tabs. You GET. You type. You PUT.

Save this as if-match-server.mjs. 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");
});
Enter fullscreen mode Exit fullscreen mode

The server holds one note at /note. GET returns the JSON and an ETag header. PUT with no extra header always overwrites. That overwrite is the mistake.

Last write wins

Fetch the note.

curl -i http://127.0.0.1:3456/note
Enter fullscreen mode Exit fullscreen mode

I ran that. This came back:

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "1"
Content-Length: 27

{"text":"hello","etag":"1"}
Enter fullscreen mode Exit fullscreen mode

That is tab one. It still has hello and ETag "1". Tab one starts editing. It does not send the ETag back yet.

Tab two saves a different sentence. No extra header. This is the second tab hitting save.

curl -i -X PUT http://127.0.0.1:3456/note \
  -H 'Content-Type: application/json' \
  -d '{"text":"from tab two"}'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34

{"text":"from tab two","etag":"2"}
Enter fullscreen mode Exit fullscreen mode

GET the note again.

curl -i http://127.0.0.1:3456/note
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34

{"text":"from tab two","etag":"2"}
Enter fullscreen mode Exit fullscreen mode

hello is gone. Tab two wrote last, so tab two won. Tab one still shows hello on screen. The server does not. If tab one saves now with no extra header, it will wipe tab two the same way.

Send If-Match

Tab one still holds ETag "1". Send that token on the PUT. Do not write without it.

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"}'
Enter fullscreen mode Exit fullscreen mode

I ran that. This came back:

HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "2"
Content-Length: 31

{"error":"Precondition Failed"}
Enter fullscreen mode Exit fullscreen mode

GET the note.

curl -i http://127.0.0.1:3456/note
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2"
Content-Length: 34

{"text":"from tab two","etag":"2"}
Enter fullscreen mode Exit fullscreen mode

The stale write stopped. The stored text did not change. from tab one is not on the server.

Now send the ETag the last GET 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"}'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "3"
Content-Length: 42

{"text":"from tab two, edited","etag":"3"}
Enter fullscreen mode Exit fullscreen mode

The write went through. The ETag bumped to "3".

If-Match means only do this if the resource still matches this ETag. ETag is the version token the server sent on GET. If-Match is a request header. Comparison is strong, byte-for-byte. A weak tag like W/"1" never matches it. This is not If-None-Match. If-None-Match is the cache shortcut that returns 304.

No header: last write wins.
A stale header returns 412 Precondition Failed.
The body stays put.
A current header returns 200, and the version moves.

GET, then PUT with that ETag. That is the pair. If someone else wrote in between, you get 412 instead of a silent overwrite.

Check

Change the note. Replay an old If-Match. Confirm 412. Confirm the body did not revert.

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"}'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "3"
Content-Length: 31

{"error":"Precondition Failed"}
Enter fullscreen mode Exit fullscreen mode

GET:

curl -i http://127.0.0.1:3456/note
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "3"
Content-Length: 42

{"text":"from tab two, edited","etag":"3"}
Enter fullscreen mode Exit fullscreen mode

should not land is not in the body. The old token did not rewind the note.

If this server is already at ETag "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 again. The text should stay on the current sentence.

After the first 412, is the note still from tab two?

Top comments (1)

Collapse
 
crdtcto profile image
Kane Lim

This is a great practical example of why optimistic concurrency control matters. The two-curl setup makes the difference between “last write wins” and conditional updates very clear.

The important detail is that the server validates If-Match against the current representation before mutating anything, so a stale client gets 412 and the stored state remains untouched.

I also like the distinction from If-None-Match easy to mix those up when first working with HTTP caching and concurrency.

In a real API, I’d probably also return the current representation/version with the 412, or expose enough information for the client to re-fetch and offer a merge/retry flow. The conflict itself is only half the UX problem; deciding how the client recovers is where things get interesting.

Simple example, but it demonstrates an important production pattern very well.