API Caching Strategies
Caching is one of the most effective strategies for improving API performance, reducing server load, and providing faster response times to clients. In this lesson, we'll explore comprehensive caching strategies including HTTP caching headers, ETags, Cache-Control directives, Laravel caching mechanisms, and conditional requests.
Why API Caching Matters
Without proper caching, every API request triggers database queries, business logic processing, and response formatting. This becomes problematic as your API scales:
- Performance: Repeated requests for unchanged data waste server resources
- Bandwidth: Transferring identical responses consumes unnecessary bandwidth
- User Experience: Slow responses frustrate users and hurt engagement
- Cost: Higher server loads require more infrastructure investment
Industry Standard: Major APIs like Twitter, GitHub, and Stripe heavily rely on caching to serve millions of requests efficiently. GitHub's API, for example, uses aggressive caching to reduce database load by over 80%.
HTTP Caching Headers Overview
HTTP provides built-in caching mechanisms through standardized headers that control how responses are cached by browsers, CDNs, and proxy servers:
// Response with caching headers
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 14 Feb 2026 10:00:00 GMT
Expires: Wed, 14 Feb 2026 11:00:00 GMT
Vary: Accept-Encoding, Accept-Language
Let's break down each header and understand its purpose:
Cache-Control Header
The Cache-Control header is the primary mechanism for defining caching policies. It supports multiple directives that control caching behavior:
Common Cache-Control Directives
public - Response can be cached by any cache (browser, CDN, proxy)
private - Response can only be cached by the browser (not shared caches)
no-cache - Cache must validate with server before using cached response
no-store - Response must never be cached anywhere
max-age=N - Response is fresh for N seconds
s-maxage=N - Like max-age but only for shared caches (CDNs)
must-revalidate - Stale cache must be revalidated before use
immutable - Response will never change (useful for versioned assets)
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use App\Models\Product;
class ProductController extends Controller
{
/**
* Get all products with caching
*/
public function index(Request $request)
{
$products = Product::with('category')->get();
return response()->json([
'data' => $products
])
->header('Cache-Control', 'public, max-age=3600')
->header('Vary', 'Accept, Accept-Language');
}
/**
* Get single product with private caching
*/
public function show($id)
{
$product = Product::with('reviews')->findOrFail($id);
// Private cache for user-specific data
return response()->json([
'data' => $product
])
->header('Cache-Control', 'private, max-age=600');
}
/**
* Sensitive data - no caching
*/
public function privateData(Request $request)
{
$userData = $request->user()->privateInformation();
return response()->json([
'data' => $userData
])
->header('Cache-Control', 'no-store, no-cache, must-revalidate')
->header('Pragma', 'no-cache');
}
}
</div>
ETag (Entity Tag) Implementation
ETags are unique identifiers generated for each version of a resource. When content changes, the ETag changes, allowing clients to determine if their cached version is still valid:
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Models\Article;
class ArticleController extends Controller
{
/**
* Get article with ETag support
*/
public function show(Request $request, $id)
{
$article = Article::with('author', 'tags')->findOrFail($id);
// Generate ETag based on content
$etag = md5(json_encode($article) . $article->updated_at);
// Check if client has valid cached version
if ($request->header('If-None-Match') === $etag) {
return response()->noContent()->setStatusCode(304)
->header('ETag', $etag)
->header('Cache-Control', 'public, max-age=3600');
}
// Return fresh response with ETag
return response()->json([
'data' => $article
])
->header('ETag', $etag)
->header('Cache-Control', 'public, max-age=3600');
}
/**
* Get article list with strong ETag
*/
public function index(Request $request)
{
$page = $request->get('page', 1);
$perPage = $request->get('per_page', 20);
$articles = Article::with('author')
->published()
->paginate($perPage);
// Generate strong ETag (includes all content)
$etag = '"' . md5(
$articles->items()->toJson() .
$articles->lastPage() .
$articles->total()
) . '"';
// Conditional request handling
if ($request->header('If-None-Match') === $etag) {
return response()->noContent()
->setStatusCode(304)
->header('ETag', $etag);
}
return response()->json($articles)
->header('ETag', $etag)
->header('Cache-Control', 'public, max-age=600');
}
}
</div>
Strong vs Weak ETags: Strong ETags (without W/ prefix) indicate byte-for-byte identical content. Weak ETags (W/"...") indicate semantically equivalent content but may have minor differences like whitespace changes.
Last-Modified Header and Conditional Requests
The Last-Modified header provides a timestamp-based alternative to ETags. Clients can use If-Modified-Since to request resources only if they've changed:
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Models\BlogPost;
use Carbon\Carbon;
class BlogPostController extends Controller
{
/**
* Get blog post with Last-Modified support
*/
public function show(Request $request, $id)
{
$post = BlogPost::with('comments')->findOrFail($id);
$lastModified = $post->updated_at->toRfc7231String();
// Check If-Modified-Since header
$ifModifiedSince = $request->header('If-Modified-Since');
if ($ifModifiedSince &&
strtotime($ifModifiedSince) >= $post->updated_at->timestamp) {
return response()->noContent()
->setStatusCode(304)
->header('Last-Modified', $lastModified)
->header('Cache-Control', 'public, max-age=1800');
}
return response()->json([
'data' => $post
])
->header('Last-Modified', $lastModified)
->header('Cache-Control', 'public, max-age=1800');
}
/**
* Combined ETag and Last-Modified approach
*/
public function showWithBoth(Request $request, $id)
{
$post = BlogPost::findOrFail($id);
$etag = md5($post->content . $post->updated_at);
$lastModified = $post->updated_at->toRfc7231String();
// Check both conditional headers
$noneMatch = $request->header('If-None-Match') === $etag;
$notModified = $request->header('If-Modified-Since') &&
strtotime($request->header('If-Modified-Since')) >=
$post->updated_at->timestamp;
if ($noneMatch || $notModified) {
return response()->noContent()
->setStatusCode(304)
->header('ETag', $etag)
->header('Last-Modified', $lastModified);
}
return response()->json(['data' => $post])
->header('ETag', $etag)
->header('Last-Modified', $lastModified)
->header('Cache-Control', 'public, max-age=3600');
}
}
</div>
Laravel Application-Level Caching
Beyond HTTP caching, Laravel provides powerful application-level caching to store expensive query results, API responses, and computed data:
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use App\Models\User;
use App\Models\Product;
class CachedApiController extends Controller
{
/**
* Cache expensive database queries
*/
public function dashboard(Request $request)
{
$userId = $request->user()->id;
// Cache for 1 hour with user-specific key
$dashboardData = Cache::remember(
"user.{$userId}.dashboard",
3600,
function () use ($userId) {
return [
'stats' => $this->getUserStats($userId),
'recent_orders' => $this->getRecentOrders($userId),
'recommendations' => $this->getRecommendations($userId),
];
}
);
return response()->json(['data' => $dashboardData]);
}
/**
* Cache with tags for easy invalidation
*/
public function products(Request $request)
{
$category = $request->get('category');
$page = $request->get('page', 1);
$cacheKey = "products.category.{$category}.page.{$page}";
$products = Cache::tags(['products', "category:{$category}"])
->remember($cacheKey, 1800, function () use ($category, $page) {
return Product::where('category_id', $category)
->with('images', 'reviews')
->paginate(20, ['*'], 'page', $page);
});
return response()->json($products)
->header('Cache-Control', 'public, max-age=1800');
}
/**
* Invalidate cache when data changes
*/
public function updateProduct(Request $request, $id)
{
$product = Product::findOrFail($id);
$product->update($request->validated());
// Invalidate all product caches with tags
Cache::tags(['products', "category:{$product->category_id}"])
->flush();
return response()->json([
'message' => 'Product updated successfully',
'data' => $product
]);
}
/**
* Cache forever with explicit invalidation
*/
public function settings()
{
$settings = Cache::rememberForever('app.settings', function () {
return [
'site_name' => config('app.name'),
'features' => Feature::enabled()->get(),
'maintenance' => $this->getMaintenanceStatus(),
];
});
return response()->json(['data' => $settings])
->header('Cache-Control', 'public, max-age=86400');
}
}
</div>
Cache Invalidation Challenge: One of the hardest problems in computer science is cache invalidation. Always implement clear strategies for when and how to invalidate cached data to avoid serving stale information.
Vary Header for Content Negotiation
The Vary header tells caches that different versions of a resource exist based on certain request headers:
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Models\Content;
class ContentController extends Controller
{
/**
* Content varies by Accept-Language
*/
public function show(Request $request, $id)
{
$locale = $request->header('Accept-Language', 'en');
$content = Content::findOrFail($id);
$localizedContent = $content->translate($locale);
return response()->json([
'data' => $localizedContent
])
->header('Cache-Control', 'public, max-age=3600')
->header('Vary', 'Accept-Language')
->header('Content-Language', $locale);
}
/**
* Response varies by multiple headers
*/
public function data(Request $request)
{
$format = $request->header('Accept');
$encoding = $request->header('Accept-Encoding');
$language = $request->header('Accept-Language');
$data = $this->getData();
return response()->json($data)
->header('Cache-Control', 'public, max-age=1800')
->header('Vary', 'Accept, Accept-Encoding, Accept-Language');
}
}
</div>
Caching Middleware Implementation
Create reusable middleware to apply consistent caching policies across your API:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ApiCacheHeaders
{
/**
* Add cache headers to API responses
*/
public function handle(Request $request, Closure $next, ...$params)
{
$response = $next($request);
// Only apply to successful responses
if ($response->status() !== 200) {
return $response;
}
// Parse middleware parameters
$cacheType = $params[0] ?? 'public';
$maxAge = $params[1] ?? 3600;
// Apply cache headers
$response->header('Cache-Control', "{$cacheType}, max-age={$maxAge}");
// Add ETag if not present
if (!$response->headers->has('ETag')) {
$etag = md5($response->getContent());
$response->header('ETag', $etag);
// Handle conditional requests
if ($request->header('If-None-Match') === $etag) {
return response()->noContent()
->setStatusCode(304)
->header('ETag', $etag)
->header('Cache-Control', "{$cacheType}, max-age={$maxAge}");
}
}
return $response;
}
}
</div>
Register and use the middleware in routes:
// app/Http/Kernel.php
protected $middlewareAliases = [
'cache.headers' => \App\Http\Middleware\ApiCacheHeaders::class,
];
// routes/api.php
Route::get('/products', [ProductController::class, 'index'])
->middleware('cache.headers:public,3600');
Route::get('/user/profile', [UserController::class, 'profile'])
->middleware('cache.headers:private,600');
</div>
Cache Strategies for Different Resource Types
<?php
namespace App\Services;
class CacheStrategyService
{
/**
* Static content (rarely changes)
* Example: site settings, country lists
*/
public function staticContent()
{
return [
'Cache-Control' => 'public, max-age=86400, immutable',
'max_age' => 86400, // 24 hours
];
}
/**
* Semi-static content (changes occasionally)
* Example: blog posts, product catalogs
*/
public function semiStaticContent()
{
return [
'Cache-Control' => 'public, max-age=3600, must-revalidate',
'max_age' => 3600, // 1 hour
];
}
/**
* Dynamic content (changes frequently)
* Example: trending products, news feed
*/
public function dynamicContent()
{
return [
'Cache-Control' => 'public, max-age=300, must-revalidate',
'max_age' => 300, // 5 minutes
];
}
/**
* User-specific content
* Example: user profile, shopping cart
*/
public function privateContent()
{
return [
'Cache-Control' => 'private, max-age=600',
'max_age' => 600, // 10 minutes
];
}
/**
* Real-time content (should not be cached)
* Example: live stock prices, authentication tokens
*/
public function realTimeContent()
{
return [
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
'max_age' => 0,
];
}
}
</div>
Practice Exercise:
- Create a cached API endpoint for product listings with proper Cache-Control headers
- Implement ETag support that generates tags based on product data and updated_at timestamp
- Add middleware that automatically handles If-None-Match conditional requests
- Create a cache invalidation strategy that flushes product cache when products are updated
- Implement Vary header support for different Accept-Language values
- Add application-level caching using Cache::remember() with a 30-minute TTL
- Test your implementation by making requests with If-None-Match header and verify 304 responses
Best Practices Summary
- Choose appropriate max-age values: Balance freshness with cache efficiency
- Use ETags for frequently updated resources: Allows efficient revalidation
- Implement both HTTP and application caching: Multi-layer caching strategy
- Always include Vary headers: Ensure correct content negotiation caching
- Use private for user-specific data: Prevent shared cache issues
- Never cache sensitive data: Use no-store for authentication tokens
- Implement cache invalidation: Clear cache when underlying data changes
- Monitor cache hit rates: Track effectiveness of your caching strategy
- Test conditional requests: Verify 304 responses work correctly
- Document caching behavior: Help API consumers understand caching policies
Performance Monitoring: Use tools like Laravel Telescope or custom metrics to track cache hit/miss rates. A well-implemented caching strategy should achieve 60-80% cache hit rates for most read-heavy APIs.