backendperformance

Lima's Deep ETag Fix: Bypassing Envelope Timestamps for HTTP Caching

· 5 min read

We enabled HTTP conditional caching on Lima’s hottest GET endpoints: /dashboard, /insights, /subscription, /entitlements. Enabled it, deployed it, monitored. Then checked the numbers: 304 responses were near zero. Every request came back as a full 200, even for data that hadn’t changed. The cache was running and doing nothing.

What was breaking it

Lima’s API wraps every response in a standard envelope: {success, data, timestamp}. The timestamp is set to Instant.now() on every response. By design, it’s always new.

Our first approach used Spring’s ShallowEtagHeaderFilter. It does exactly what the name says: hashes the entire response body. Because timestamp changed on every request, the full-body hash changed too. The If-None-Match header the client sent would never match a freshly generated ETag, so 304 never fired. The filter was hashing volatile metadata and calling it a cache key.

The fix: hash only the payload

We wrote HttpConditional, a helper that generates a “deep ETag”: a SHA-256 hash of only the data field, ignoring the envelope.

Two details matter for correctness.

First, canonical serialization. For endpoints like /entitlements where data is a Map, the JVM doesn’t guarantee iteration order. Two calls with identical data can produce different byte arrays. We configure ObjectWriter with ORDER_MAP_ENTRIES_BY_KEYS so the same data always hashes to the same ETag.

Second, per-user namespace. The ETag is scoped to userId. Without this, two accounts with coincidentally identical payloads would share an ETag, which is wrong.

public String generateDeepEtag(Object dataPayload, String userId) {
    try {
        byte[] payloadBytes = mapper.writer().writeValueAsBytes(dataPayload);
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(payloadBytes);
        String base64Hash = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
        return String.format("W/\"%s-%s\"", userId, base64Hash);
    } catch (Exception e) {
        return null;
    }
}

The controller calls HttpConditional after receiving the request, validates the incoming If-None-Match against the computed ETag, and returns 304 directly if they match. The ShallowEtagHeaderFilter is out of the picture.

Result

With server.compression (gzip) already in place, a 304 now means zero payload bytes on a response that would otherwise ship a compressed JSON body. The hot endpoints went from 100% full responses to the expected mix.

The lesson: if your envelope adds volatile fields, a body-level hash will never cache. Hash the payload, not the wrapper.


Boundary correctness kept coming up while building Lima. Another example: WhatsApp phone matching for Brazilian numbers, where the same user can show up under two different E.164 strings depending on when they registered. That’s in The Brazilian ninth digit.