Eliminate Redundant Database Hits with ETags, Cache-Control, and Redis
HTTP Caching and Redis Response Cache
Layer HTTP caching into a Koa 2 app using koa-conditional-get and koa-etag for 304 responses, then add a Redis-backed middleware cache to serve repeated requests without touching the database.
What you'll learn
- Enable ETag-based conditional GET responses with koa-etag and koa-conditional-get
- Set Cache-Control headers to control browser and CDN caching behaviour
- Build a Redis response cache middleware that short-circuits downstream handlers
Caching is the fastest way to scale a Koa API. Two complementary strategies exist: HTTP caching (the browser and CDN avoid round-trips entirely) and server-side response caching (the server skips expensive database queries).
HTTP Caching: ETag + Conditional GET
npm install koa-etag koa-conditional-get Add both middleware near the top of the stack, before your router:
import Koa from 'koa';
import conditional from 'koa-conditional-get';
import etag from 'koa-etag';
const app = new Koa();
app.use(conditional()); // checks If-None-Match / If-Modified-Since
app.use(etag()); // adds ETag header based on response body hash When a browser or CDN sends a request with If-None-Match: "<etag>",
koa-conditional-get compares it against the current ETag. If they match,
the response is truncated to a 304 Not Modified — no body is transmitted.
Cache-Control Headers
Set Cache-Control per route to tell clients how long to cache a response.
router.get('/products', async (ctx) => {
ctx.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
ctx.body = await productService.list();
});
router.get('/me', async (ctx) => {
ctx.set('Cache-Control', 'private, no-store'); // never cache user-specific data
ctx.body = await userService.getById(ctx.state.userId);
}); | Directive | Meaning |
|---|---|
| public | Shared caches (CDN) may store the response |
| private | Only the browser may cache; CDN must not |
| max-age=N | Cache is fresh for N seconds |
| no-store | Never cache — fetch fresh every time |
| stale-while-revalidate=N | Serve stale while fetching fresh in the background |
Redis Response Cache Middleware
For expensive queries, cache the serialised response in Redis and serve it directly on repeat requests.
npm install ioredis // middleware/redisCache.js
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export function responseCache(ttlSeconds = 60) {
return async (ctx, next) => {
if (ctx.method !== 'GET') return next();
const key = `cache:${ctx.url}`;
const cached = await redis.get(key);
if (cached) {
ctx.set('X-Cache', 'HIT');
ctx.type = 'application/json';
ctx.body = cached;
return;
}
await next();
if (ctx.status === 200 && ctx.body) {
const serialised = typeof ctx.body === 'string'
? ctx.body
: JSON.stringify(ctx.body);
await redis.setex(key, ttlSeconds, serialised);
ctx.set('X-Cache', 'MISS');
}
};
} Apply it to specific routes:
import { responseCache } from '../middleware/redisCache.js';
router.get('/products', responseCache(120), async (ctx) => {
ctx.body = await productService.list();
}); Cache Invalidation
Invalidate the Redis key whenever the underlying data changes:
router.post('/products', async (ctx) => {
const product = await productService.create(ctx.request.body);
await redis.del('cache:/products'); // bust the list cache
ctx.status = 201;
ctx.body = product;
}); Up Next
Learn how to offload slow or resource-intensive work to a BullMQ background queue so your handlers respond immediately.
Background Jobs with BullMQ →