Why Caching Matters for REST API Design
I spent three years at CodeBrew Labs optimizing Android apps, but the real bottleneck wasn't the client—it was the backend. We had 6 production apps on the Play Store, each hammering our REST APIs with millions of requests daily. Our database was maxed out, latency spiked during peak hours, and our infrastructure costs were climbing.
Then we implemented a serious caching strategy, and everything changed.
Within two weeks, we reduced database load by 70%, cut response times from 800ms to 150ms, and slashed our cloud bills by $8,000 a month. That's when I realized: API performance isn't about faster databases—it's about not hitting the database at all.
Whether you're building with Node.js backend solutions or Laravel, mastering REST API design with intelligent caching is non-negotiable. This post shares the exact patterns I've used to scale APIs handling 100K+ RPS.
Multi-Layer Caching Architecture
Most teams implement caching as an afterthought—they add Redis and call it a day. That's a mistake. Real API performance comes from a layered approach:
- Browser/Client Cache: Static assets, immutable data. 1-7 days TTL.
- CDN Cache: HTML, images, rarely-changing endpoints. Minutes to hours TTL.
- API Gateway Cache: Responses for identical requests. 1-30 minute TTL.
- Application Cache (Redis/Memcached): Expensive queries, user-specific data. 5-60 minute TTL.
- Database Query Cache: Connection pooling, prepared statements. Always on.
I designed this stack for AudioBook AI, which hit 50K+ users. Without layered caching, our PDF-to-audio conversion pipeline would've needed 10x the infrastructure. With it, we stayed lean and responsive.
"Caching is the most practical optimization you can make. Not caching is basically leaving money on the table."
Node.js + Redis: Implementation Pattern
Let me show you the pattern I use for every Node.js backend project. This is battle-tested code from production systems.
// services/cacheService.js
const redis = require('redis');
const client = redis.createClient({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD,
});
class CacheService {
// Get from cache or fetch from database
async getOrFetch(key, fetchFn, ttl = 300) {
try {
const cached = await client.get(key);
if (cached) {
console.log(`Cache HIT: ${key}`);
return JSON.parse(cached);
}
} catch (err) {
console.warn(`Cache read error: ${err.message}`);
// Fall through to fetch
}
// Cache miss—fetch from source
const data = await fetchFn();
// Store in cache asynchronously (don't block response)
client.setex(key, ttl, JSON.stringify(data))
.catch(err => console.warn(`Cache write error: ${err.message}`));
return data;
}
// Invalidate specific key
async invalidate(key) {
await client.del(key);
console.log(`Cache invalidated: ${key}`);
}
// Invalidate pattern (e.g., user:123:*)
async invalidatePattern(pattern) {
const keys = await client.keys(pattern);
if (keys.length > 0) {
await client.del(keys);
console.log(`Cache invalidated ${keys.length} keys matching ${pattern}`);
}
}
}
module.exports = new CacheService();Now, integrate this into your REST API endpoints:
// routes/users.js
const express = require('express');
const cacheService = require('../services/cacheService');
const db = require('../db');
const router = express.Router();
// GET /api/users/:id
router.get('/:id', async (req, res) => {
const userId = req.params.id;
const cacheKey = `user:${userId}`;
try {
const user = await cacheService.getOrFetch(
cacheKey,
() => db.query('SELECT * FROM users WHERE id = ?', [userId]),
600 // 10 minute TTL
);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/users/:id (invalidate cache on update)
router.put('/:id', async (req, res) => {
const userId = req.params.id;
const cacheKey = `user:${userId}`;
try {
const result = await db.query(
'UPDATE users SET ? WHERE id = ?',
[req.body, userId]
);
// Invalidate cache after update
await cacheService.invalidate(cacheKey);
res.json({ success: true, message: 'User updated' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;This pattern is production-ready. It handles cache misses gracefully, doesn't block responses on cache writes, and provides clean invalidation hooks.
Laravel Cache: TTL & Invalidation
I've also built REST API backends with Laravel. The framework's caching layer is excellent—more intuitive than building from scratch in Node.js, in my opinion.
// app/Http/Controllers/UserController.php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class UserController extends Controller
{
// GET /api/users/{id}
public function show($id)
{
$cacheKey = "user.{$id}";
// 10-minute cache with remember()
$user = Cache::remember($cacheKey, 600, function () use ($id) {
return User::findOrFail($id);
});
return response()->json($user);
}
// PUT /api/users/{id}
public function update($id)
{
$user = User::findOrFail($id);
$user->update(request()->all());
// Invalidate specific user cache
Cache::forget("user.{$id}");
// Invalidate related caches (e.g., user lists)
Cache::tags(['users'])->flush();
return response()->json($user);
}
}
// config/cache.php
return [
'default' => env('CACHE_DRIVER', 'redis'),
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'serializer' => 'json',
],
],
];Laravel's Cache::remember() is elegant. It tries the cache, and if it misses, executes the closure and stores the result automatically. The Cache::tags() system also makes bulk invalidation much cleaner than manual key patterns.
Cache Invalidation: The Hard Problem
Phil Karlton famously said: "There are only two hard things in Computer Science: cache invalidation and naming things." I've made every mistake in the book.
Here are the patterns that actually work:
1. Time-Based (TTL) Invalidation
Simplest approach. Set a reasonable TTL and let it expire. Works for most read-heavy APIs.
- User profile: 10-30 minutes
- Product catalog: 1-2 hours
- Leaderboards: 5 minutes
- Static config: 24 hours
2. Event-Driven Invalidation
When data changes, immediately bust the cache. Requires webhooks or pub/sub:
// After creating a new order
await Order.create(orderData);
// Publish event
await pubsub.publish('order.created', {
orderId: order.id,
userId: order.user_id,
});
// In a listener service:
pubsub.subscribe('order.created', async (event) => {
// Invalidate user's order list cache
await cacheService.invalidatePattern(`user:${event.userId}:orders:*`);
// Invalidate dashboard totals
await cacheService.invalidate('dashboard:revenue:today');
});3. Dependency Tracking
Some caches depend on others. Keep a dependency graph:
- Invalidating
user:123should also invalidateuser:123:posts,user:123:followers - Invalidating
product:456should invalidatecategory:electronics:products
Pro tip: Don't over-invalidate. If you're clearing too much cache, your TTLs are too long or your architecture is too coupled.
Measuring Impact on API Performance
You can't improve what you don't measure. After implementing caching, track these metrics:
Cache Hit Ratio
Aim for 80%+ on read-heavy endpoints.
// Middleware to track cache metrics
const cacheMetrics = {
hits: 0,
misses: 0,
getHitRatio() {
const total = this.hits + this.misses;
return total === 0 ? 0 : (this.hits / total * 100).toFixed(2);
}
};
app.use((req, res, next) => {
const originalJson = res.json;
res.json = function(data) {
const isCacheHit = res.cacheHit;
if (isCacheHit) cacheMetrics.hits++;
else cacheMetrics.misses++;
return originalJson.call(this, data);
};
next;
});P95 Response Time
Most endpoints should respond <200ms with caching. Without caching, database queries alone take 300-800ms.
Database Query Count
Track queries per request. A well-cached API should make 1-2 database queries per request, not 10+.
Infrastructure Cost
Document monthly spend before and after caching. At Raybit, we reduced cloud costs by 35% just through intelligent caching—no code refactors needed.
📊 Real Numbers
On a system handling 50K requests/min: without caching = 180 database connections, $4,200/month. With caching = 12 connections, $650/month. Same scale, 85% cheaper.
Key Takeaways
- Layered caching matters more than any single optimization. Browser → CDN → API Gateway → Redis → Database. Each layer reduces load on the next.
- Use TTL-based caching for 80% of cases. It's simple, fault-tolerant, and requires zero invalidation logic. Event-driven caching is for the remaining 20% where freshness is critical.
- Cache write operations should be async and non-blocking. Never let cache failures slow down your API response. Treat cache as an optimization, not a critical path.
- Track hit ratio and P95 response times. Without metrics, you're flying blind. Aim for 80%+ cache hit ratio and <200ms P95 latency on read endpoints.
- Invalidation is harder than caching itself. Start conservative with long TTLs, then tighten based on freshness requirements. Over-invalidation kills the benefits of caching entirely.
⚠️ Common Mistake
Caching without proper invalidation strategy will serve stale data and confuse users. A 5-minute TTL with zero invalidation is better than a 24-hour TTL with half-broken event listeners.