Skip to content

HATEOAS Caching — Caching Hypermedia Responses with ETags and Cache-Control

DodaTech Updated 2026-06-28 6 min read

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` },
    },
  });
});
// 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;
    },
  };
}
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

  1. Caching user-specific hypermedia publicly — if links contain user-specific actions, don't cache publicly. Use Cache-Control: private.
  2. Not varying by Authorization header — without Vary: Authorization, a cached response for user A may be served to user B, showing wrong links.
  3. Using the same TTL for all links — self links rarely change, action links change with state. Use different TTLs for different link types.
  4. Not invalidating cache on state transitions — when a device goes from active to quarantined, cached device resources with old links must be invalidated.
  5. Ignoring stale-while-revalidate — this pattern lets clients use cached links while fetching fresh ones in the background, reducing visible latency.

Practice Questions

  1. How does ETag-based caching work for hypermedia resources?
  2. Why must cache vary by Authorization header?
  3. How do state transitions affect hypermedia cache invalidation?
  4. What Cache-Control directives are appropriate for action links?
  5. 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

Should I cache hypermedia responses in a CDN?

Only for public, unauthenticated resources. CDN-cached responses are shared across users. If links contain user-specific actions, use private caching only.

How do I invalidate cache when links change?

Two approaches: set short TTLs (30s) so caches expire naturally, or actively purge cached URLs when the resource state changes. Most CDNs support purge APIs.

What is stale-while-revalidate?

It tells caches to serve stale content while fetching fresh content in the background. This eliminates the cache miss penalty for the user.

How does Vary: Authorization affect caching?

It tells caches to store separate copies for each Authorization header value. Each user gets their own cached response with user-specific links.

Can I cache link URLs separately from resource data?

Yes. Cache the URL mapping in the client (rel -> URL) with a longer TTL than the resource data. Only re-discover links when the cached URL returns 404.

What's Next

Learn HATEOAS API design

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro