[EN] Conditional Writes in Spring Boot: Prevent Lost Updates with If-Match

Julian | Jul 17, 2026 min read

The second part of our series was about caching headers - and in the pro tip about strong vs. weak ETags we briefly teased that strong ETags have a second job: They protect you from lost updates when writing. That’s exactly what today is about.

You know how it is - two people editing the same data set almost simultaneously via an API. Admin A opens a product record, changes the price and saves. Shortly before, Admin B opened the same data record, entered a different price and also saved it. Who wins? For a naive PUT endpoint: who saves last. Admin A’s change ends up in the database, completely overwriting B’s change - and no one gets an error, a warning or even a line in the log. B’s job is simply gone.

This is the classic lost update problem, and HTTP has had a built-in mechanism for this for decades: conditional requests with If-Match and If-Unmodified-Since. In this article we’ll look at why naive write access has this problem, how you can solve it cleanly with HTTP preconditions, how this relates to classic optimistic locking via JPA @Version - and how you actually implement it in Spring Boot.

The problem: Lost updates

Imagine the product endpoint from the last two articles, this time while writing instead of reading. A very common PUT endpoint, like most CRUD APIs probably have:

// Anti-Pattern: Der zweite Request gewinnt einfach, ohne dass jemand etwas merkt
@PutMapping("/products/{id}")
public ResponseEntity<Product> updateProduct(
        @PathVariable Long id,
        @RequestBody ProductUpdateRequest updateRequest) {

    Product updated = productService.update(id, updateRequest);
    return ResponseEntity.ok(updated);
}

Functionally, there’s nothing wrong with this - the endpoint does exactly what it’s supposed to do. The problem only becomes apparent when two clients call it almost simultaneously:

  1. Admin A loads the product: price €100.
  2. Admin B loads the same product, also price €100.
  3. Admin B changes the price to €80 and saves. Server stand: €80.
  4. Admin A never saw B’s change, changes his (outdated) status to €90 and saves. Server stand: €90.

B’s price change has completely disappeared - not because it was incorrect, but because A’s write access was blindly based on an outdated data status and simply overwritten it. No server error, no exception log, no HTTP status code to indicate anything. A’s request looks like any other successful ‘PUT’ - and that’s exactly what makes lost updates so tricky: they don’t stand out because of errors, but because of silent data loss that you only notice when someone complains that their change is “just gone”.

Technically, the server did everything it knew correctly. The real problem: He didn’t even know that A was working on an old version because the request never included this information. Conditional writes close exactly this gap.

The solution: Conditional Writes with If-Match

HTTP solves the lost update problem with the same building block that we learned about caching in the last article: the ETag. Only this time not as a cache validation, but as a Precondition - a condition that the client attaches to its write access: “Only execute this PUT if the resource has been unchanged since my last read.”

The process looks almost identical to the caching validation from part 2, only with the roles reversed:

  1. Client reads the resource via GET, gets ETag: "42" back.
  2. Client wants to change the resource and sends PUT with If-Match: "42".
  3. Server compares: Is the current ETag of the resource still "42"? It then executes the change and returns a new ETag. If the ETag is now different - because someone else has saved it in the meantime - the server rejects the change with 412 Precondition Failed, without writing anything.

Applied to our example from above: A’s PUT would send If-Match: "<ETag of A's original GET>" along with it. Because B has saved in the meantime and thus changed the ETag, A gets a 412 back - instead of silently overwriting B’s change. A sees the error, can reload the current status and consciously reapply his change.

Important, and this brings us full circle to the pro tip from part 2: For If-Match the specification requires a strong ETag. A weak ETag (W/"42") is not allowed for preconditions because it only guarantees semantic equality - that’s not enough for a concurrency check, you need byte-by-byte certainty that nothing has really changed.

If you don’t have a version field for an ETag at hand, but you do have an updatedAt timestamp, there is the counterpart to Last-Modified from Part 2: If-Unmodified-Since. The server will deny write access if the resource was modified after the specified time:

PUT /products/42 HTTP/1.1
If-Unmodified-Since: Fri, 10 Jul 2026 08:00:00 GMT
Content-Type: application/json

{ "price": 90 }

Just like reading: If-Unmodified-Since is less precise (second precision), you don’t need an additional version field for that. If-Match is the more precise but also more complex variant.

If-MatchIf-Unmodified-Since
baseETag (strong)Timestamp
Precisionexactlyto the second
RequiresVersion or hash fieldupdatedAt timestamp
In case of violation412 Precondition Failed412 Precondition Failed

A point that is often overlooked: Without sending the If-Match header at all, the protection does not work - the server then simply has no condition to check and the PUT runs normally. The precondition is optional from the server’s perspective unless you explicitly enforce it. We’ll take a look at how to do this as a pro tip.

Conditional Writes in der Praxis: Spring Boot

The good news first: Unlike QUERY from part 1, you don’t need a workaround here. Spring supports conditional writes via the same mechanism we used for caching in Part 2 - WebRequest.checkNotModified(). The difference is in the HTTP method: For GET/HEAD the method checks If-None-Match and sets 304 if there is a match. For all other methods - including PUT - it checks If-Match and sets 412 if there is a non match, instead of changing anything itself.

@PutMapping("/products/{id}")
public ResponseEntity<Product> updateProduct(
        @PathVariable Long id,
        @RequestBody ProductUpdateRequest updateRequest,
        WebRequest request) {

    ProductMeta meta = productService.findMeta(id); // günstig: nur id, version
    String etag = "\"" + meta.version() + "\"";

    if (request.checkNotModified(etag)) {
        // Kein oder veraltetes If-Match -> Spring hat bereits
        // 412 Precondition Failed gesetzt, nichts weiter zu tun
        return null;
    }

    Product updated = productService.update(id, updateRequest, meta.version());
    return ResponseEntity.ok()
            .eTag("\"" + updated.version() + "\"")
            .body(updated);
}

The trick is the same as when reading in Part 2: findMeta() only loads the version field, not the entire entity. The precondition is checked before an expensive update operation is initiated - if it fails, you have neither created unnecessary database load nor accidentally written anything.

The gap that HTTP checks alone cannot close

There is a small time window between the checkNotModified() call and the actual productService.update(). In theory, a second request can change the same resource in exactly this window - then your update() would still write to a version that is now out of date, even though the HTTP precondition gave the green light beforehand. The HTTP check drastically reduces the lost update risk, but does not eliminate it 100 percent because there is no atomic connection between checking and writing.

That’s why it’s worth a second backup at the persistence level - classic optimistic locking via JPA:

@Entity
public class Product {

    @Id
    private Long id;

    private String name;
    private BigDecimal price;

    @Version
    private long version;

    // Getter, Setter
}
@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public Product update(Long id, ProductUpdateRequest updateRequest, long expectedVersion) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));

        product.setName(updateRequest.name());
        product.setPrice(updateRequest.price());

        // Hibernate vergleicht die geladene @Version beim Flush automatisch
        // gegen den aktuellen DB-Stand und wirft bei Abweichung
        // ObjectOptimisticLockingFailureException
        return productRepository.save(product);
    }
}
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<Void> handleOptimisticLock(ObjectOptimisticLockingFailureException ex) {
    return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}

This gives you two levels that work together instead of duplicating each other: The ‘If Match’ check at the API boundary intercepts the normal case before a transaction even starts - and documents the concurrency behavior explicitly in the HTTP contract of your API, visible to every client. @Version at the database level catches the rare race in the remaining time window, as the last instance that is still correct even if two requests really arrive at exactly the same time. One doesn’t replace the other - it’s Defense in Depth.

Pro Tips/Warnings

Tip: If-Match not only protects against lost updates when changing, but also when creating. If you just want to create a resource with PUT and are guaranteed not to overwrite any existing ones, send If-None-Match: *. This tells the server: “Execute this request only if nothing exists under this URI.”

@PutMapping("/products/{id}")
public ResponseEntity<Product> createProduct(
@PathVariable Long id,
@RequestBody ProductCreateRequest createRequest,
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {

if (!"*".equals(ifNoneMatch)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED).build();
}
if (productService.exists(id)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
Product created = productService.create(id, createRequest);
return ResponseEntity.status(HttpStatus.CREATED).eTag("\"" + created.version() + "\"").body(created);
}

Two parallel “Create” requests for the same ID can no longer overwrite each other - exactly the same principle as If-Match, only in reverse.

Warning: Without sending the If-Match header at all, the protection doesn’t work at all - the precondition is optional from the server’s perspective as long as you don’t actively enforce it. If you want to make sure that no one “forgets” to send a condition, respond explicitly to missing preconditions with 428 Precondition Required (RFC 6585):

@RequestHeader(value = "If-Match", required = false) String ifMatch
// ...
if (ifMatch == null) {
return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED).build();
}

This is how the server turns optional protection into a mandatory part of the API contract - clients that ignore this clearly fail from the start instead of quietly overwriting data later.

Warning: Do not confuse 412 with 409 Conflict. 412 Precondition Failed means: a condition explicitly sent by the client (‘If-Match’, ‘If-Unmodified-Since’) does not apply - purely technically, regardless of the technical content of the change. 409 Conflict means: the request technically conflicts with the current status of the resource, regardless of whether a precondition was set at all - such as a duplicate, actually unique product name. A client that treats both as “some 4xx error” loses exactly the information it would need to decide: reload and try again (412) or show the user a technical error (409).

Conclusion

Lost Updates are like the other problems in this series: not a bug that crashes, but one that simply produces incorrect results without complaint. If-Match and If-Unmodified-Since solve this by making explicit a piece of information that is completely missing from a naive PUT - what status the client actually believes it is working on. The server can then check this assumption instead of blindly trusting it.

As you have seen, a two-stage approach is worthwhile: the HTTP check at the API boundary as a quick, client-visible safeguard - and @Version at the persistence level as a safety net for the small remaining time window that the HTTP check alone cannot close. Neither level replaces the other, and together they create insurance that actually lasts.

In this series we have covered everything from a completely new HTTP method to caching and concurrency control - three building blocks that are often only used superficially in everyday API life, although it is in the details (weak vs. strong ETag, no-cache vs. no-store, 412 vs. 409) that the real difference between “mostly works” and “really works” lies.

If-Match protects you when changing existing resources - but what about POST, where there is no resource and therefore no ETag? It’s exactly this related problem, duplicate orders caused by network retries instead of overridden price changes, that we’ll look at in the next part: Idempotency Keys.