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.