HATEOAS Caching — Caching Hypermedia Responses with ETags and Cache-Control
In this tutorial, you will learn about HATEOAS Caching. We cover key concepts, practical examples, and best practices to help you master this topic.
HATEOAS caching applies HTTP caching mechanisms to hypermedia resources, using ETags for conditional requests, Cache-Control for freshness, and targeted invalidation when resource state changes invalidate links.
What You'll Learn
- ETags for hypermedia validation caching
- Cache-Control directives for link freshness
- Cache invalidation on state transitions
- Varying caches by authenticated user
- Caching discovered URLs client-side
- Stale-while-revalidate for links
Why It Matters
Hypermedia responses contain both data and links. Caching reduces latency for repeated access to the same resources. But links change when resource state changes, so cache invalidation must account for state transitions. DodaTech's Durga Antivirus Pro caches device resources for 30 seconds, invalidating immediately when the device state changes.
Real-World Use
A monitoring dashboard polls device status every 10 seconds. The device resource includes links to available actions. When the device goes offline, the state changes and the ETag changes. The dashboard gets a fresh response with updated links (no quarantine action, new reconnect action). The cache is automatically invalidated by the state change.
sequenceDiagram
participant Client
participant Cache
participant API
Client->>Cache: GET /devices/1
Cache->>API: GET /devices/1 (no cache)
API-->>Cache: 200 + ETag: "abc"
Cache-->>Client: 200 + data + links
Client->>Cache: GET /devices/1 (If-None-Match: "abc")
Cache->>API: Conditional request
API-->>Cache: 304 Not Modified
Cache-->>Client: 304 (cached response)
Note over API: Device state changes
API->>Cache: Invalidate /devices/1
Client->>Cache: GET /devices/1 (If-None-Match: "abc")
Cache->>API: Conditional request
API-->>Cache: 200 + ETag: "def" + new links
Cache-->>Client: 200 + updated links
Code Examples
Example 1: ETags for Hypermedia Resources
const express = require('express');
const crypto = require('crypto');
const app = express();
// Generate ETag from resource state
function generateETag(resource) {
const hash = crypto.createHash('sha256');
hash.update(JSON.stringify({
id: resource.id,
status: resource.status,
updatedAt: resource.updatedAt,
links: resource._links,
}));
return `"${hash.digest('hex').substring(0, 16)}"`;
}
// Conditional request middleware
function conditionalGet(req, res, next) {
const originalJson = res.json.bind(res);
res.json = function(body) {
// Generate ETag
const etag = generateETag(body);
res.set('ETag', etag);
// Check If-None-Match
const clientEtag = req.headers['if-none-match'];
if (clientEtag === etag) {
return res.status(304).end();
}
return originalJson(body);
};
next();
}
app.get('/devices/:id', conditionalGet, (req, res) => {
const device = getDevice(req.params.id);
const stateLinks = getStateActions(device);
res.json({
...device,
_links: {
self: { href: `/devices/${device.id}` },
...stateLinks,
},
});
});
// Invalidate cache on state change
app.post('/devices/:id/quarantine', (req, res) => {
const device = updateDeviceState(req.params.id, 'quarantined');
// Setting a new ETag invalidates cached versions
res.set('ETag', generateETag(device));
res.set('Cache-Control', 'no-cache');
res.json({
...device,
_links: {
self: { href: `/devices/${device.id}` },
'dt:clean': { href: `/devices/${device.id}/clean` },
},
});
});
Example 2: Cache-Control Strategies for Links
// Different caching strategies for different resources
const cachePolicies = {
// Device resources: short TTL, revalidate on state change
device: {
'Cache-Control': 'public, max-age=30, must-revalidate',
},
// Link-intensive collection: medium TTL
collection: {
'Cache-Control': 'public, max-age=60, stale-while-revalidate=30',
},
// Action endpoints: no caching (state-sensitive)
action: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
// Static documentation links: long TTL
documentation: {
'Cache-Control': 'public, max-age=86400, immutable',
},
// User-specific resources: private cache
userResource: {
'Cache-Control': 'private, max-age=10',
},
};
// Apply caching policy based on content type
function applyCachePolicy(req, res, next) {
const originalJson = res.json.bind(res);
res.json = function(body) {
// Determine resource type from response
const type = determineType(req, body);
const policy = cachePolicies[type] || cachePolicies.collection;
for (const [key, value] of Object.entries(policy)) {
res.set(key, value);
}
// Add Vary header for content negotiation
res.set('Vary', 'Accept, Authorization');
return originalJson(body);
};
next();
}
// Link-specific caching
function withLinkCache(link, ttlSeconds) {
return {
href: link,
cacheTTL: ttlSeconds,
cachedAt: Date.now(),
isExpired() {
return Date.now() - this.cachedAt > this.ttlSeconds * 1000;
},
};
}
Example 3: Client-Side Link Caching
class HypermediaCache {
constructor(defaultTTL = 30000) {
this.cache = new Map();
this.defaultTTL = defaultTTL;
this.pendingRequests = new Map();
}
async get(url, options = {}) {
const cacheKey = this.makeKey(url, options);
const cached = this.cache.get(cacheKey);
// Return cached response if fresh
if (cached && !this.isExpired(cached, options)) {
console.log(`Cache HIT: ${url}`);
return cached.data;
}
// Deduplicate in-flight requests
if (this.pendingRequests.has(cacheKey)) {
return this.pendingRequests.get(cacheKey);
}
console.log(`Cache MISS: ${url}`);
const fetchPromise = this.fetchAndCache(url, options, cacheKey);
this.pendingRequests.set(cacheKey, fetchPromise);
try {
return await fetchPromise;
} finally {
this.pendingRequests.delete(cacheKey);
}
}
async fetchAndCache(url, options, cacheKey) {
const headers = { ...options.headers };
const cached = this.cache.get(cacheKey);
// Conditional request
if (cached && cached.etag) {
headers['If-None-Match'] = cached.etag;
}
const res = await fetch(url, { ...options, headers });
if (res.status === 304) {
// Cache is still valid
cached.expiresAt = Date.now() + this.getTTL(res);
return cached.data;
}
const data = await res.json();
const etag = res.headers.get('ETag');
const cacheControl = res.headers.get('Cache-Control');
// Cache the response
this.cache.set(cacheKey, {
data,
etag,
expiresAt: Date.now() + this.getTTL(res),
links: data._links || {},
});
// Also cache discovered links separately
if (data._links) {
for (const [rel, link] of Object.entries(data._links)) {
this.linkCache.set(rel, { url: link.href, expiresAt: Date.now() + 60000 });
}
}
return data;
}
// Invalidate specific links when state changes
invalidateLinks(resourceId) {
const patterns = [`/devices/${resourceId}`, `/devices/${resourceId}/`];
for (const key of this.cache.keys()) {
if (patterns.some(p => key.includes(p))) {
this.cache.delete(key);
}
}
}
}
Common Mistakes
- Caching user-specific hypermedia publicly — if links contain user-specific actions, don't cache publicly. Use Cache-Control: private.
- Not varying by Authorization header — without Vary: Authorization, a cached response for user A may be served to user B, showing wrong links.
- Using the same TTL for all links — self links rarely change, action links change with state. Use different TTLs for different link types.
- Not invalidating cache on state transitions — when a device goes from active to quarantined, cached device resources with old links must be invalidated.
- Ignoring stale-while-revalidate — this pattern lets clients use cached links while fetching fresh ones in the background, reducing visible latency.
Practice Questions
- How does ETag-based caching work for hypermedia resources?
- Why must cache vary by Authorization header?
- How do state transitions affect hypermedia cache invalidation?
- What Cache-Control directives are appropriate for action links?
- How does stale-while-revalidate improve perceived performance?
Challenge: Design a caching Strategy for a hypermedia API with: short TTL for action links (5s), medium TTL for collection links (60s), long TTL for static links (1h), ETag-based validation for all resources, and automatic invalidation on state transitions.
Mini Project
Build a caching layer for a HATEOAS API with: ETag generation from resource state and links, Cache-Control policies per resource type, conditional request handling, automatic invalidation on state transitions, Vary header for authenticated resources, and stale-while-revalidate support.
FAQ
What's Next
Learn HATEOAS API design
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro