Skip to main content
surviving the spike

Cache-Control, ETag, and Last-Modified: The Headers That Save Your Origin

9 min read Chapter 14 of 66

Cache-Control, ETag, and Last-Modified: The Headers That Save Your Origin

Three headers control whether your origin processes a request or a CDN edge node handles it in under 10ms. Get them right and 85% of your traffic never reaches your servers. Get them wrong, either by omission or by misconfiguration, and your origin absorbs every single request at full cost.

This section covers each header in detail, maps them to specific ride-hailing endpoints, and shows the Spring WebFlux implementation for each pattern.

Cache-Control Combinations for the Ride-Hailing Platform

Every endpoint in the platform falls into one of five caching categories. Each category requires a different Cache-Control strategy.

Category 1: Public Semi-Static Data (Fare Estimates, Non-Surge)

Fare estimates for a given origin-destination pair change at most once per minute during normal operations. The fare depends on distance, base rate, and time-of-day multiplier. None of these change per-user.

// SCALED: Fare estimate with tiered caching
@GetMapping("/estimate")
public Mono<ResponseEntity<FareEstimate>> estimateFare(
        @RequestParam String originZone,
        @RequestParam String destZone,
        ServerHttpRequest request) {

    return fareService.calculate(originZone, destZone)
        .map(estimate -> {
            String etag = computeETag(estimate);

            // 304 if client already has this version
            if (matchesETag(request, etag)) {
                return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                    .eTag(etag)
                    .cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))
                        .sMaxAge(Duration.ofSeconds(60))
                        .staleWhileRevalidate(Duration.ofSeconds(30)))
                    .<FareEstimate>build();
            }

            return ResponseEntity.ok()
                .eTag(etag)
                .cacheControl(CacheControl.maxAge(Duration.ofSeconds(30))
                    .sMaxAge(Duration.ofSeconds(60))
                    .staleWhileRevalidate(Duration.ofSeconds(30)))
                .body(estimate);
        });
}

The resulting headers:

Cache-Control: max-age=30, s-maxage=60, stale-while-revalidate=30
ETag: "5f3a1c9e"

The CDN caches for 60 seconds (s-maxage). The browser caches for 30 seconds (max-age). If a request arrives between 60 and 90 seconds after the last fetch, the CDN serves the stale version immediately and revalidates in the background (stale-while-revalidate=30). The user sees no delay. The origin receives a background revalidation request at most once per 60 seconds per unique origin-destination pair.

Category 2: Public Volatile Data (Surge Pricing, Driver Zones)

Surge pricing multipliers change every 10 seconds when surge is active. Driver availability zone aggregates update on similar intervals. These are public (same for all users) but change too fast for long cache times.

// SCALED: Short-lived CDN cache for volatile public data
@GetMapping("/surge")
public Mono<ResponseEntity<SurgeInfo>> getSurgeInfo(
        @RequestParam String zoneId,
        ServerHttpRequest request) {

    return surgeService.getCurrentSurge(zoneId)
        .map(surge -> {
            String etag = "\"surge-" + zoneId + "-" + surge.version() + "\"";

            if (matchesETag(request, etag)) {
                return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                    .eTag(etag)
                    .cacheControl(CacheControl.noCache()
                        .sMaxAge(Duration.ofSeconds(10)))
                    .<SurgeInfo>build();
            }

            return ResponseEntity.ok()
                .eTag(etag)
                .cacheControl(CacheControl.noCache()
                    .sMaxAge(Duration.ofSeconds(10)))
                .body(surge);
        });
}

The resulting headers:

Cache-Control: no-cache, s-maxage=10
ETag: "surge-downtown-v4821"

The CDN caches for 10 seconds. The browser always revalidates (no-cache), but the CDN absorbs the revalidation request if the response is still within its 10-second window. The origin handles at most 6 requests per minute per zone instead of thousands.

Category 3: Private User-Specific Data (Trip History, Payment Methods)

Trip history is different for every user. A CDN must never cache it. Only the user’s browser should cache it.

// SCALED: Private caching for user-specific data
@GetMapping("/history")
public Mono<ResponseEntity<List<Trip>>> getTripHistory(
        @AuthenticationPrincipal UserPrincipal user,
        @RequestParam(defaultValue = "10") int limit) {

    return tripService.getHistory(user.getId(), limit)
        .collectList()
        .map(trips -> {
            String etag = "\"trips-" + user.getId() + "-"
                + trips.hashCode() + "\"";

            return ResponseEntity.ok()
                .eTag(etag)
                .cacheControl(CacheControl.maxAge(Duration.ofSeconds(300))
                    .cachePrivate())
                .body(trips);
        });
}

The resulting headers:

Cache-Control: private, max-age=300
ETag: "trips-u8291-8c4f2a"

private tells the CDN: do not cache this response. The browser caches it for 5 minutes. If the user navigates back to the trip history screen within 5 minutes, the browser serves it from its local cache. Zero network requests.

Category 4: Real-Time State (Driver Location, Active Trip)

Active driver locations update every 1-2 seconds. Active trip status changes on every driver action (accepted, arrived, started, completed). Caching these produces stale data that directly harms user experience. A rider seeing a driver location that is 30 seconds old will call support.

// SCALED: No-store for real-time data
@GetMapping("/driver/{driverId}/location")
public Mono<ResponseEntity<DriverLocation>> getDriverLocation(
        @PathVariable String driverId) {

    return locationService.getCurrentLocation(driverId)
        .map(location -> ResponseEntity.ok()
            .cacheControl(CacheControl.noStore())
            .body(location));
}

The resulting headers:

Cache-Control: no-store

No ETag. No Last-Modified. No caching of any kind. Every request goes to the origin. For real-time endpoints, this is correct.

Category 5: Immutable Assets (Zone Boundary GeoJSON, Static Config)

Zone boundary definitions change only when the operations team redraws service areas. This happens at most a few times per year. The URL includes a content hash, so when the boundary changes, the URL changes.

// SCALED: Immutable caching for versioned static content
@GetMapping("/zones/{zoneId}/boundary/{contentHash}")
public Mono<ResponseEntity<GeoJsonFeature>> getZoneBoundary(
        @PathVariable String zoneId,
        @PathVariable String contentHash) {

    return zoneService.getBoundary(zoneId, contentHash)
        .map(boundary -> ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(Duration.ofDays(365))
                .cachePublic()
                .immutable())
            .body(boundary));
}

The resulting headers:

Cache-Control: public, max-age=31536000, immutable

The browser and CDN cache for one year. The immutable directive tells the browser: do not revalidate, even on a manual reload. The content hash in the URL guarantees that a new version gets a new URL.

ETag Generation Strategies

An ETag is an opaque identifier for a specific version of a response. The origin generates it, sends it in the response, and uses it to check if the client’s cached version is still current.

Three strategies, each with different tradeoffs.

Strategy 1: Content Hash

Hash the response body or key fields. The ETag changes when the content changes.

// Content hash ETag: always accurate, but requires computing the response first
private String contentHashETag(FareEstimate estimate) {
    String content = estimate.originZone() + ":"
        + estimate.destZone() + ":"
        + estimate.totalCents() + ":"
        + estimate.surgeMultiplier() + ":"
        + estimate.estimatedMinutes();
    return "\"" + Integer.toHexString(content.hashCode()) + "\"";
}

Pros: perfectly accurate, changes only when content changes. Cons: you must compute the full response before you can generate the ETag, which means you cannot short-circuit the database query on a conditional request.

Strategy 2: Version Field

Store a version counter or timestamp in the database. Increment it when the data changes.

// Version field ETag: can short-circuit without computing response
private Mono<String> versionETag(String originZone, String destZone) {
    return fareVersionStore.getVersion(originZone, destZone)
        .map(version -> "\"fare-" + originZone + "-" + destZone
            + "-v" + version + "\"");
}

// Usage: check version before computing fare
@GetMapping("/estimate")
public Mono<ResponseEntity<FareEstimate>> estimateFare(
        @RequestParam String originZone,
        @RequestParam String destZone,
        ServerHttpRequest request) {

    return versionETag(originZone, destZone)
        .flatMap(etag -> {
            // Short-circuit: if ETag matches, skip fare calculation entirely
            if (matchesETag(request, etag)) {
                return Mono.just(
                    ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                        .eTag(etag)
                        .cacheControl(fareCacheControl())
                        .<FareEstimate>build()
                );
            }

            // ETag mismatch: compute the fare
            return fareService.calculate(originZone, destZone)
                .map(estimate -> ResponseEntity.ok()
                    .eTag(etag)
                    .cacheControl(fareCacheControl())
                    .body(estimate));
        });
}

Pros: the origin can return 304 without computing the response. A single Redis GET to check the version replaces the full fare calculation. Cons: requires maintaining version state.

Strategy 3: Last-Modified Timestamp

Use Last-Modified instead of ETag when you have a natural timestamp.

// Last-Modified for trip history: natural modification timestamp
@GetMapping("/history")
public Mono<ResponseEntity<List<Trip>>> getTripHistory(
        @AuthenticationPrincipal UserPrincipal user,
        ServerHttpRequest request) {

    return tripService.getLastModified(user.getId())
        .flatMap(lastModified -> {
            // Check If-Modified-Since header
            Instant ifModifiedSince = request.getHeaders()
                .getIfModifiedSince() > 0
                ? Instant.ofEpochMilli(
                    request.getHeaders().getIfModifiedSince())
                : Instant.MIN;

            if (!lastModified.isAfter(ifModifiedSince)) {
                return Mono.just(
                    ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                        .lastModified(lastModified)
                        .<List<Trip>>build()
                );
            }

            return tripService.getHistory(user.getId(), 10)
                .collectList()
                .map(trips -> ResponseEntity.ok()
                    .lastModified(lastModified)
                    .cacheControl(CacheControl.maxAge(Duration.ofSeconds(300))
                        .cachePrivate())
                    .body(trips));
        });
}

Use Last-Modified when the data has a natural “last updated” timestamp. Use ETag for everything else. If you provide both, CDNs prefer ETag.

Common Mistakes

Mistake 1: max-age on Personalized Content

// BOTTLENECK: max-age without private on user-specific data
@GetMapping("/profile")
public Mono<ResponseEntity<UserProfile>> getProfile(
        @AuthenticationPrincipal UserPrincipal user) {
    return profileService.get(user.getId())
        .map(profile -> ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(Duration.ofSeconds(600)))
            .body(profile));
}

Without private, the CDN caches this response. The next user requesting /profile gets the previous user’s profile. This is a data leak.

// SCALED: private + max-age for user-specific data
.cacheControl(CacheControl.maxAge(Duration.ofSeconds(600))
    .cachePrivate())

Mistake 2: Forgetting Vary: Authorization

When a response depends on the Authorization header but is also cached by the CDN, the CDN must key the cache on the Authorization header. Otherwise, User B gets User A’s cached response.

But Vary: Authorization creates a unique cache entry per user token, destroying the cache hit rate. The correct approach: if the response is user-specific, use Cache-Control: private and do not cache at the CDN level at all. If the response is the same for all authenticated users but different for unauthenticated users, use Vary: Authorization with a short s-maxage.

Mistake 3: CDN Caching Error Responses

Your origin returns a 500 error. The CDN caches it because your Cache-Control header says s-maxage=60. For the next 60 seconds, every user gets the cached 500 error. The origin has already recovered, but the CDN keeps serving the error.

// SCALED: Only set cache headers on successful responses
return fareService.calculate(originZone, destZone)
    .map(estimate -> ResponseEntity.ok()
        .eTag(computeETag(estimate))
        .cacheControl(fareCacheControl())
        .body(estimate))
    .onErrorResume(e -> Mono.just(
        ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .cacheControl(CacheControl.noStore())  // Never cache errors
            .body(null)));

Mistake 4: Setting max-age=0 Instead of no-cache

max-age=0 means the response is immediately stale. The cache may still serve it if revalidation fails (e.g., origin is down). no-cache means the cache must revalidate before serving. Use no-cache when freshness is required.

The Utility Method

Every controller in the ride-hailing platform uses a shared utility for ETag matching:

// SCALED: Reusable ETag matching utility
public final class CacheUtils {

    private CacheUtils() {}

    public static boolean matchesETag(ServerHttpRequest request, String etag) {
        List<String> ifNoneMatch = request.getHeaders().getIfNoneMatch();
        return ifNoneMatch.contains(etag) || ifNoneMatch.contains("*");
    }

    public static String computeETag(Object... fields) {
        StringBuilder sb = new StringBuilder();
        for (Object field : fields) {
            sb.append(field).append(":");
        }
        return "\"" + Integer.toHexString(sb.toString().hashCode()) + "\"";
    }

    public static CacheControl publicShortLived(int sMaxAgeSeconds) {
        return CacheControl
            .sMaxAge(Duration.ofSeconds(sMaxAgeSeconds))
            .staleWhileRevalidate(
                Duration.ofSeconds(sMaxAgeSeconds / 2));
    }

    public static CacheControl privateBrowserOnly(int maxAgeSeconds) {
        return CacheControl
            .maxAge(Duration.ofSeconds(maxAgeSeconds))
            .cachePrivate();
    }
}

This keeps cache configuration consistent across endpoints and eliminates the class of bugs where one developer uses max-age where another uses s-maxage for the same endpoint type.