skip to content

How do you implement conditional GETs with WebRequest.checkNotModified so you can return 304 without doing expensive work?

level: seniorimportance: must knowfreq 48%

answer

  1. inject WebRequest, checkNotModified(marker)
  2. true -> return null, Spring sends 304
  3. call BEFORE expensive load (saves compute)
  4. epoch MILLIS not seconds
  5. unsafe methods -> 412 not 304; 1s timestamp resolution

basics

~20 s

Inject WebRequest, compute a cheap version marker (Last-Modified timestamp or ETag), and call webRequest.checkNotModified(marker). If it returns true, return null — Spring sends 304 Not Modified. If false, build and return the full 200 response. This lets you skip rendering when unchanged.

solid answer

~40 s

WebRequest.checkNotModified lets the handler do validation early, before the costly work. You accept a WebRequest parameter, obtain a lightweight validator — a lastModified epoch-millis, an ETag string, or both via checkNotModified(etag, lastModified). Spring compares it against the request's If-Modified-Since and If-None-Match headers. If they match (unchanged), the method returns true, Spring sets the response to 304 Not Modified, and your controller should immediately return null/empty without touching the DB or rendering a body. If it returns false, the resource changed (or it's a first request): you proceed to load and return the full 200 body, and Spring has already set the Last-Modified/ETag headers for you. Unlike ShallowEtagHeaderFilter, this actually saves server compute because you short-circuit before the expensive step. For non-safe methods a failed precondition yields 412 instead of 304.

code

java · 29 lines
java
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;

@RestController
@RequestMapping("/api/articles")
class ArticleController {

    private final ArticleService service;
    ArticleController(ArticleService service) { this.service = service; }

    @GetMapping("/{id}")
    ResponseEntity<ArticleDto> get(@PathVariable long id, WebRequest webRequest) {
        // 1) cheap lookup of a version marker only (no full load / rendering)
        long lastModifiedMillis = service.findLastModifiedMillis(id);

        // 2) short-circuit BEFORE the expensive work
        if (webRequest.checkNotModified(lastModifiedMillis)) {
            // Spring already set status 304 + Last-Modified header
            return null; // no body
        }

        // 3) resource changed (or first request): do the expensive work now
        ArticleDto dto = service.loadFull(id);
        return ResponseEntity.ok(dto);
    }

    // ETag variant: checkNotModified(etag) or checkNotModified(etag, lastModified)
}

go deeper

for a junior

Know that returning 304 means 'unchanged, no body' and Spring can do it for you.

for a middle

Use checkNotModified with a Last-Modified value and return null on true.

for a senior

Order the check before the expensive load, choose ETag vs Last-Modified deliberately, and know the 304 vs 412 distinction and millis-not-seconds gotcha.

for a principal

Design validator computation to be genuinely cheap, tie ETags to optimistic-concurrency/versioning strategy, and combine freshness (max-age) with validation across the API for CDN and client efficiency.

## Conditional requests recap HTTP lets a client say "only send the body if it changed": it echoes a previously received validator back in a request header — `If-Modified-Since` (a date, paired with `Last-Modified`) or `If-None-Match` (an ETag). If the resource is unchanged the server returns `304 Not Modified` with no body; otherwise `200 OK` with the fresh body. ## The Spring API `org.springframework.web.context.request.WebRequest` (inject it as a handler parameter) exposes overloads: - `boolean checkNotModified(long lastModifiedTimestamp)` — epoch **milliseconds**; compares to `If-Modified-Since`. - `boolean checkNotModified(String etag)` — an ETag value (Spring quotes it if unquoted); compares to `If-None-Match`. - `boolean checkNotModified(String etag, long lastModifiedTimestamp)` — both, per the HTTP precedence rules (ETag wins when present). ## Return-value contract The boolean tells you what to do: - **`true`** → the request is conditional and the resource is unchanged. Spring has already set the response status to **304** (and appropriate headers). Your handler must **return immediately without a body** (return `null`, or from a `@ResponseBody` method just return null). Doing more work or returning a body is wrong. - **`false`** → either the resource changed or the request had no matching precondition (e.g. first visit). Proceed to build the full response. Spring will emit the `Last-Modified`/`ETag` headers you supplied so the client can revalidate next time. ## Why this beats the shallow filter The whole point is **ordering**: you compute a *cheap* validator first (a `updatedAt` column, a version number, a hash you already store) and call `checkNotModified` **before** loading the full entity, calling downstream services, or rendering. On a match you skip all of that. `ShallowEtagHeaderFilter`, by contrast, runs the handler to completion and only saves bandwidth. Use `checkNotModified` when recomputation is expensive. ## Safe vs unsafe methods For safe methods (`GET`/`HEAD`) a matched precondition returns **304**. For state-changing methods (`PUT`/`DELETE` with `If-Match`/`If-Unmodified-Since` semantics) a failed precondition results in **412 Precondition Failed** — this underpins optimistic concurrency ("update only if you have the current version"). ## Timestamp precision gotcha `Last-Modified`/`If-Modified-Since` have **1-second** resolution on the wire. If your entity changes multiple times within the same second, a millisecond-precise `updatedAt` can round down and a client may miss an update (or get a spurious 304). ETags avoid this because they identify exact versions — prefer ETags when sub-second changes matter. ## Other gotchas - Pass epoch **millis**, not seconds; a common bug is passing `Instant.getEpochSecond()`. - `checkNotModified` also sets the response header for you, so don't also manually set `Last-Modified`/`ETag` to a different value. - It must be called *before* you commit/flush the response. - Works with `ResponseEntity` too, but the WebRequest form is the idiomatic early-exit pattern. ## When to use which - Cheap-to-render, want zero code → `ShallowEtagHeaderFilter`. - Expensive-to-render, have a cheap version marker → `WebRequest.checkNotModified`. - Want freshness without any round trip → `Cache-Control: max-age`. Often combine max-age (freshness) + ETag/Last-Modified (validation).

  • How is checkNotModified fundamentally different from ShallowEtagHeaderFilter?
    checkNotModified is called before the expensive work using a cheap validator, so a 304 skips loading/rendering entirely — saving compute and bandwidth. The filter runs the handler to completion and only avoids sending the body, saving bandwidth alone.
  • When would you prefer an ETag over a Last-Modified timestamp in checkNotModified?
    When a resource can change more than once per second: Last-Modified/If-Modified-Since only has 1-second wire resolution, so sub-second changes can be missed. ETags identify exact versions and are also needed for byte-exact validation and optimistic concurrency (If-Match -> 412).

saying these in an interview costs you the question

  • Continuing to load/render after checkNotModified returns true
  • Passing epoch seconds instead of milliseconds
  • Believing it returns a body on 304
  • Thinking Last-Modified has millisecond precision on the wire
  • Confusing its compute-saving behavior with the shallow filter's bandwidth-only saving

context