HTTP caching mechanisms for scalable APIs
A REST API that loads the same data from the database on every request and sends it back in full wastes bandwidth and server resources. ETag, Last-Modified and Cache-Control are the HTTP standard tools that prevent exactly that, with 304 Not Modified for unchanged resources and proxy caching for public data.
Table of Contents
- 1. HTTP caching fundamentals for REST APIs
- 2. Cache-Control: directives and what they mean
- 3. ETag: strong and weak validators
- 4. Last-Modified and conditional requests
- 5. The 304 Not Modified flow in detail
- 6. Caching in Symfony: the Response class and HttpCache
- 7. The Vary header: segmenting caches by request properties
- 8. Caching strategy comparison: which pattern for which case
- 9. Summary
- 10. FAQ
1. HTTP caching fundamentals for REST APIs
HTTP caching is not an optional feature but a fundamental part of the HTTP protocol. RFC 9111 precisely defines how clients, proxies and servers should handle cache data. For REST APIs, three caching mechanisms are relevant: freshness caching (Cache-Control with max-age), where the client uses a stored response for a defined period without contacting the server; validation caching (ETag and Last-Modified), where the client asks whether the resource has changed; and proxy caching, where a reverse proxy (Varnish, Nginx) stores responses for all clients.
The decisive difference between these three mechanisms lies in which part of the processing chain is relieved. Freshness caching avoids requests entirely, which is good for static or rarely changing data. Validation caching reduces the amount of data transferred, but the server still has to handle a conditional request, which is good for frequently queried resources that rarely change. Proxy caching relieves the application server completely for all public resources. In a typical REST API, all three mechanisms are used in parallel, applied to different endpoints.
2. Cache-Control: directives and what they mean
The Cache-Control header is the most important cache header in HTTP/1.1 and HTTP/2. It controls which caches may store a resource and for how long. The most important directives for REST APIs: no-store prevents any storage at all, for sensitive data such as authentication responses and personal user data; no-cache allows storage, but the client must validate before every use; private allows only client-side caching, no proxy caching, for user-specific data; public allows proxy caching; max-age=N defines the lifetime in seconds.
The combination of directives determines the exact behavior. Cache-Control: public, max-age=300 allows proxy caches to store the resource for 5 minutes and serve it to all clients. Cache-Control: private, max-age=60, must-revalidate allows the browser to use the resource for 60 seconds, after which it must validate. Cache-Control: no-cache, no-store prevents any caching at all, typical for authentication endpoints and live currency rates. Choosing the right directives for each endpoint is a business decision, not just a technical one.
<?php
// src/Controller/ProductController.php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ProductRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
final class ProductController
{
public function __construct(
private readonly ProductRepository $products,
) {}
#[Route('/products/{id}', methods: ['GET'])]
public function show(int $id, Request $request): Response
{
$product = $this->products->find($id);
if ($product === null) {
return new JsonResponse(['code' => 'PRODUCT_NOT_FOUND'], 404);
}
// Generate ETag from content hash (strong validator)
$data = [
'id' => $product->getId(),
'name' => $product->getName(),
'price' => $product->getPrice(),
'updatedAt' => $product->getUpdatedAt()->format(\DateTimeInterface::RFC3339),
];
$etag = '"' . md5(json_encode($data, JSON_THROW_ON_ERROR)) . '"';
$lastModified = $product->getUpdatedAt();
$response = new JsonResponse($data);
$response->setEtag($etag);
$response->setLastModified($lastModified);
$response->setPublic();
$response->setMaxAge(300); // 5 min fresh for proxies
$response->setSharedMaxAge(300); // s-maxage=300 for CDN/Varnish
// Check if client already has a valid copy (304 if not modified)
if ($response->isNotModified($request)) {
return $response; // Symfony sends 304 automatically
}
return $response;
}
#[Route('/products', methods: ['GET'])]
public function list(Request $request): Response
{
$products = $this->products->findAll();
$data = array_map(fn($p) => ['id' => $p->getId(), 'name' => $p->getName()], $products);
// Use collection-level ETag (hash of all product IDs + updatedAt)
$hashInput = implode(',', array_map(
fn($p) => $p->getId() . ':' . $p->getUpdatedAt()->getTimestamp(),
$products
));
$etag = '"' . md5($hashInput) . '"';
$response = new JsonResponse($data);
$response->setEtag($etag);
$response->setPublic();
$response->setSharedMaxAge(60); // Collections expire faster
if ($response->isNotModified($request)) {
return $response;
}
return $response;
}
}
3. ETag: strong and weak validators
An ETag (Entity Tag) is a unique identifier for a specific version of a resource. Strong ETags (in double quotes: "abc123") guarantee that the resource content has not changed byte for byte. Weak ETags (with a W/ prefix: W/"abc123") signal that the resource is semantically equivalent, but not necessarily byte-identical, which is useful when a compressed and an uncompressed version should count as equivalent.
ETags are generated from the response content, typically as an MD5 or SHA256 hash of the serialized JSON body or directly from a version field of the entity. For database entities with an updatedAt timestamp, a hash of id + updatedAt is often sufficient and avoids having to serialize the full record. ETags are more reliable than Last-Modified, because they do not depend on server time and detect changes within the same second, which matters for fast writes in a high-frequency API.
4. Last-Modified and conditional requests
The Last-Modified header gives the timestamp of a resource's last change as an HTTP date. Clients store this value and send it back on subsequent requests in the If-Modified-Since header. The server compares the submitted timestamp against the resource's actual modification time: if it has not changed, it responds with 304 Not Modified and no body. If it has changed, it sends the current data with the new Last-Modified timestamp.
The advantage of Last-Modified over ETag is simpler implementation: an updatedAt timestamp already exists on most database entities anyway. The disadvantage: Last-Modified has a granularity of one second. Multiple changes within the same second cannot be distinguished. In modern APIs, where writes can occur milliseconds apart, ETag is therefore the preferred method. In practice, combining both headers is recommended: ETag as the primary validator and Last-Modified as a fallback for older clients.
5. The 304 Not Modified flow in detail
The 304 Not Modified flow is the heart of validation caching. The sequence: the client sends GET /products/42 with If-None-Match: "abc123" (the stored ETag) and optionally If-Modified-Since: Thu, 15 Jan 2025 10:30:00 GMT. The server loads the resource, computes the current ETag and compares: if it matches, the server sends 304 Not Modified without a body, but with the same caching headers (Cache-Control, ETag, Last-Modified). The client uses its stored response body and updates the cache metadata.
The result: network overhead is reduced to the size of the headers, typically a few hundred bytes instead of several kilobytes of body. Server load is reduced to the database access needed to read the updatedAt timestamp, with no serializing, no JSON encoding, no middleware processing of the full response body. For APIs with large response bodies (product lists, search results), the bandwidth benefit is substantial. In Symfony, $response->isNotModified($request) handles the comparison and sets the 304 status automatically.
<?php
// src/EventSubscriber/CacheHeaderSubscriber.php
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Adds standard security headers and ensures cache headers are correct
* for public vs. authenticated endpoints.
*/
final class CacheHeaderSubscriber implements EventSubscriberInterface
{
private const PUBLIC_PATHS = ['/products', '/categories', '/search'];
private const NO_CACHE_PATHS = ['/auth/', '/users/me', '/orders'];
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => 'onResponse'];
}
public function onResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$response = $event->getResponse();
$path = $request->getPathInfo();
// Never cache write operations
if (!in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
$response->headers->set('Cache-Control', 'no-store');
return;
}
// Private user data: no proxy caching
foreach (self::NO_CACHE_PATHS as $prefix) {
if (str_starts_with($path, $prefix)) {
$response->setPrivate();
$response->headers->addCacheControlDirective('no-cache');
return;
}
}
// Public data: allow proxy caching
foreach (self::PUBLIC_PATHS as $prefix) {
if (str_starts_with($path, $prefix)) {
if (!$response->headers->hasCacheControlDirective('max-age')) {
$response->setPublic();
$response->setSharedMaxAge(300);
}
return;
}
}
}
}
6. Caching in Symfony: the Response class and HttpCache
Symfony provides a complete HTTP caching implementation through the Response class and the HttpCache kernel. The Response class abstracts all cache headers: setEtag(), setLastModified(), setPublic(), setPrivate(), setMaxAge(), setSharedMaxAge() and isNotModified(). The HttpCache kernel is a full reverse proxy written in PHP that can be used in development environments and in simple production setups without an external proxy.
For production environments with high traffic, an external reverse proxy (Varnish, Nginx) is more capable than the PHP-based HttpCache. Symfony HTTP Cache supports cache invalidation via the purge protocol: PURGE /products/42 immediately removes the cache entry. For more complex cache invalidation strategies (cache tags, event-based invalidation), FOS HttpCache offers a comprehensive solution that works together with Varnish, Nginx and Symfony HTTP Cache. Correct cache invalidation on writes is often more complex than setting the cache headers on reads.
7. The Vary header: segmenting caches by request properties
The Vary header tells caches that the response varies depending on certain request headers. Vary: Accept-Encoding means: a compressed and an uncompressed version of the same endpoint are cached separately. Vary: Accept-Language means: a separate cache entry is created for each language version. In REST APIs with content negotiation (Accept: application/json vs. Accept: application/ld+json), Vary: Accept prevents a JSON-LD response from being served to a client expecting plain JSON.
The Vary header has a critical side effect: every additional dimension in the Vary header multiplies the number of stored cache entries. Vary: Authorization is especially problematic: a separate cache entry would be created for every user, which is a substantial storage overhead with thousands of users. The rule of thumb: do not use proxy caching for user-specific data (Cache-Control: private instead of Vary: Authorization). Use Vary only for dimensions that actually produce different response bodies.
8. Caching strategy comparison: which pattern for which case
The right caching strategy depends on three factors: how often the resource changes, whether the data is user-specific, and how much effort it takes to invalidate a cached resource. The table shows the recommended strategy for typical REST API endpoints.
| Endpoint type | Cache-Control | ETag/Last-Modified | Why |
|---|---|---|---|
| Static master data | public, max-age=3600 | ETag | Rarely changed, same for everyone |
| Product details | public, s-maxage=300 | ETag + Last-Modified | Occasionally changed, public |
| User profile | private, max-age=60 | ETag | User-specific, no proxy |
| Auth token response | no-store | None | Sensitive, never cache |
| Search results | private, no-cache | ETag (optional) | Parameter-dependent, short freshness |
Cache-Control directives are not recommendations, they are contract terms: a well-behaved cache must respect no-store. Reverse proxies like Varnish allow ignoring Cache-Control: private for certain paths and caching anyway, but that is an explicit misconfiguration, not standard behavior. Anyone relying on correct Cache-Control semantics can be confident that conformant caches will respect that behavior.
Mironsoft
REST API performance, HTTP caching and Symfony optimization
Want to implement HTTP caching for your REST API?
We analyze your API traffic, implement ETag, Last-Modified and Cache-Control for the right endpoints, and set up reverse proxy caching for maximum relief on your application server.
Caching audit
Analysis of current cache headers and identification of missing caching potential
ETag implementation
Implementing ETag and Last-Modified for all cacheable endpoints in Symfony
Proxy setup
Configuring Varnish or Nginx as a reverse proxy with cache invalidation
9. Summary
HTTP caching in REST APIs is the most effective measure for reducing bandwidth and server load without architectural changes. Cache-Control controls whether and for how long responses are cached. ETag and Last-Modified enable conditional requests and 304 Not Modified responses for unchanged resources. The Vary header segments caches by request properties. Combining these mechanisms reduces the amount of transferred data in typical REST APIs by 30 to 70 percent for frequent reads of rarely changing resources.
Symfony makes the implementation accessible with the Response class, isNotModified() and the optional HttpCache kernel. The most important design decision is not technical but a business one: which endpoint may be cached, by whom (private vs. public), and for how long? This decision must be made explicitly for every endpoint, since implicit defaults lead to security-critical or stale responses.
HTTP caching in REST APIs, the essentials at a glance
Cache-Control
public/private, max-age, s-maxage, no-store, no-cache. Set explicitly for every endpoint. Sensitive data: no-store. Public: public + s-maxage.
ETag
Hash of the response content or a version field. More reliable than Last-Modified, detects changes within the same second. In Symfony: setEtag().
304 Not Modified
isNotModified($request) in Symfony checks If-None-Match and If-Modified-Since automatically. Saves body transfer, but database access still happens.
Vary
Only use for dimensions that actually produce different bodies (Accept, Accept-Encoding). Avoid Vary: Authorization, prefer Cache-Control: private instead.