Skip to main content
Why CacheeHow It Works
All Verticals5G TelecomAd TechAI InfrastructureFraud DetectionGamingTrading
PricingDocsBlogSchedule DemoLog InStart Free Trial
← Back to Blog
Tutorial

How to Add Caching to a Node.js Express API in 5 Minutes

Your Express API hits the database on every request. Response times are 50–200ms. Users are complaining. The product team is asking why the dashboard takes three seconds to load when it only shows six numbers. Adding a cache layer takes 5 minutes and cuts response time by 10–20x. Here is the fastest path from zero to cached — two approaches, measured side by side, so you can pick the one that fits your stack.

The Starting Point

Most Express APIs follow the same pattern. A request comes in. The route handler validates parameters. It queries the database. It serializes the result into JSON. It sends the response. Every single request repeats this cycle, even when the underlying data has not changed in hours.

Here is a typical product detail endpoint. Nothing unusual — it is the kind of route that exists in tens of thousands of production Express applications right now.

const express = require('express'); const db = require('./db'); const app = express(); app.get('/api/products/:id', async (req, res) => { const product = await db.query( 'SELECT * FROM products WHERE id = $1', [req.params.id] ); if (!product.rows[0]) return res.status(404).json({ error: 'Not found' }); // Fetch related data const variants = await db.query( 'SELECT * FROM variants WHERE product_id = $1', [req.params.id] ); const reviews = await db.query( 'SELECT * FROM reviews WHERE product_id = $1 ORDER BY created_at DESC LIMIT 10', [req.params.id] ); res.json({ ...product.rows[0], variants: variants.rows, reviews: reviews.rows }); });

Three database queries per request. On a PostgreSQL instance with moderate load, each query takes 15–35ms. Total response time: 85ms average. Under load — say 500 concurrent users during a product launch — connection pool contention pushes that to 150–200ms. And the product data changes maybe twice a day. Ninety-nine percent of those database round-trips are returning identical results.

The cost of no caching: At 1,000 requests/minute to this endpoint, you are executing 3,000 database queries per minute for data that changes twice a day. That is 4.3 million unnecessary queries per day. Each one costs CPU cycles on your database, occupies a connection pool slot, and adds 85ms of latency that your users feel on every page load.

Option 1: Redis Cache-Aside (The Manual Way)

The standard approach is Redis with a cache-aside pattern: check the cache first, fall back to the database on a miss, then store the result for next time. This is what most tutorials teach and what most teams implement first.

// npm install ioredis const Redis = require('ioredis'); const redis = new Redis('redis://localhost:6379'); app.get('/api/products/:id', async (req, res) => { const cacheKey = `product:${req.params.id}`; // Step 1: Check cache const cached = await redis.get(cacheKey); if (cached) { return res.json(JSON.parse(cached)); // Cache hit } // Step 2: Cache miss — query database const product = await db.query( 'SELECT * FROM products WHERE id = $1', [req.params.id] ); if (!product.rows[0]) return res.status(404).json({ error: 'Not found' }); const variants = await db.query( 'SELECT * FROM variants WHERE product_id = $1', [req.params.id] ); const reviews = await db.query( 'SELECT * FROM reviews WHERE product_id = $1 ORDER BY created_at DESC LIMIT 10', [req.params.id] ); const result = { ...product.rows[0], variants: variants.rows, reviews: reviews.rows }; // Step 3: Store in cache with 60-second TTL await redis.set(cacheKey, JSON.stringify(result), 'EX', 60); res.json(result); });

This works. On a cache hit, you skip all three database queries and get your response from Redis instead. But the improvement is smaller than you expect. Redis on the same machine adds ~0.5ms for the GET, plus ~1.5ms for JSON.parse on a typical product payload (50–100KB with variants and reviews). Total cache-hit response time: ~45ms. That is a 1.9x improvement — real, but not the 10x you were hoping for. The database round-trip was only part of the cost. Now serialization and the Redis network hop eat into your savings.

On a cache miss, it is actually slower than the original: you pay the Redis check (0.5ms), all three database queries (85ms), the JSON.stringify for the cache write (1.5ms), and the Redis SET (0.5ms). That is 87.5ms instead of 85ms. And you still have to handle Redis connection management, TTL strategy, cache invalidation on writes, and the cold-start problem after deploys.

Redis cache-aside downsides: You must manage connection pools, handle Redis failures gracefully (what happens when Redis is down?), choose TTLs for every route, manually invalidate on writes, and deal with serialization overhead that eats 30–50% of your savings on typical payloads.

Option 2: Cachee SDK (The 3-Line Way)

The Cachee SDK takes a fundamentally different approach. Instead of adding a remote cache that your code must check, populate, and invalidate, it wraps your existing route handler and manages caching transparently. The cache lives in-process — in your application’s own memory — so there is no network hop, no serialization, and no connection pool to manage.

// npm install @cachee/sdk const { cachee } = require('@cachee/sdk'); // Initialize once const cache = cachee({ apiKey: process.env.CACHEE_API_KEY }); // Wrap your existing route — that's it app.get('/api/products/:id', cache.wrap(async (req, res) => { const product = await db.query( 'SELECT * FROM products WHERE id = $1', [req.params.id] ); if (!product.rows[0]) return res.status(404).json({ error: 'Not found' }); const variants = await db.query( 'SELECT * FROM variants WHERE product_id = $1', [req.params.id] ); const reviews = await db.query( 'SELECT * FROM reviews WHERE product_id = $1 ORDER BY created_at DESC LIMIT 10', [req.params.id] ); res.json({ ...product.rows[0], variants: variants.rows, reviews: reviews.rows }); }));

Your original route handler is unchanged. You added one import, one initialization line, and wrapped the handler with cache.wrap(). That is three lines of new code. The SDK automatically derives cache keys from the request method, path, and parameters. It stores the response object directly in process memory — no serialization to JSON and back. On a cache hit, it returns the stored object reference in ~0.002ms (2 microseconds). Total cache-hit response time: ~3ms. That is a 28x improvement over the uncached baseline.

The SDK also handles everything you would have to build manually with Redis: it uses ML-driven predictive pre-warming instead of static TTLs, so popular entries are refreshed before they expire. It automatically invalidates when it detects write operations to the same resource. And because the cache is in-process, there is zero failure surface — no Redis connection to drop, no network partition to handle, no fallback logic to write.

Why Option 2 Is 15x Faster Than Option 1

The performance gap between Redis cache-aside (45ms) and Cachee L1 (3ms) is not a small optimization. It is an architectural difference. Four factors explain the 15x gap.

No network hop. Redis runs in a separate process, usually on a separate machine. Even on localhost, a Redis GET costs 0.3–0.5ms of TCP overhead. Cachee’s L1 cache is a hash table in your Node.js process. The lookup costs 2 microseconds — 150x faster than the Redis round-trip alone. See sub-millisecond cache latency for the full architecture.

No serialization. Redis stores bytes. To cache a JavaScript object in Redis, you must JSON.stringify() it on write and JSON.parse() it on read. For a 75KB product payload, that is 1–2ms each way. Cachee stores native object references in memory. There is no encoding or decoding step. The object you cached is the object you get back, at memory-access speed.

ML-managed TTLs. With Redis, you guess a TTL. 60 seconds? 5 minutes? Too short and your hit rate drops. Too long and you serve stale data. Cachee’s predictive engine learns access patterns and refreshes entries before they are requested. It knows that /api/products/42 gets 200 requests per minute during business hours and pre-warms it 500ms before it would expire. Hit rate goes from the 85–90% you get with manual TTLs to 99%+.

Predictive pre-warming. On a cold start or after a deploy, a Redis cache has a 0% hit rate. Every request is a miss that hits the database and then writes to Redis. This “thundering herd” problem can cause latency spikes that last minutes. Cachee pre-warms the cache during startup based on historical access patterns. By the time your first request arrives, the hot data is already in memory.

The architectural difference: Redis cache-aside replaces a database network hop with a Redis network hop and adds serialization overhead. Cachee L1 eliminates the network hop entirely and stores native objects. The result is not a 2x improvement — it is a 15x improvement on typical Express payloads. See API latency optimization for more patterns.

Advanced: Per-Route Configuration

Not every route should be cached the same way. Product listings change infrequently and can tolerate stale data. User profiles need tighter freshness. Write endpoints should invalidate related cache entries. The Cachee SDK supports per-route configuration for all of these scenarios.

// Different strategies per route // Product listings: cache aggressively, key by query params app.get('/api/products', cache.wrap(handler, { ttl: '5m', key: (req) => `products:${req.query.category}:${req.query.page}` })); // User profile: shorter TTL, key by authenticated user app.get('/api/me', cache.wrap(handler, { ttl: '30s', key: (req) => `user:${req.user.id}` })); // Search results: cache by full query string app.get('/api/search', cache.wrap(handler, { ttl: '2m', key: (req) => `search:${req.query.q}:${req.query.sort}` })); // Write endpoint: invalidate related cache entries app.put('/api/products/:id', async (req, res) => { await db.query('UPDATE products SET ...'); cache.invalidate(`product:${req.params.id}`); // Instant invalidation cache.invalidate('products:*'); // Pattern-based invalidation res.json({ success: true }); });

The key function gives you full control over cache key construction. Use it to separate cached entries by user, by query parameters, by locale, or by any other request dimension. Pattern-based invalidation with cache.invalidate('products:*') clears all matching entries in a single call — no need to track individual keys. And because invalidation happens in-process, it is instantaneous: no round-trip to Redis, no pub/sub delay, no chance of a stale read between the write and the invalidation.

The Results

Here is what changes when you add Cachee to the product detail endpoint. Same Express app, same PostgreSQL database, same traffic pattern. The only difference is three lines of code.

85ms → 3ms Avg Response Time
0% → 99% Cache Hit Rate
45K → 450 DB Queries / Hour
28x Faster

Database load drops by 99%. Response times drop by 96.5%. Your users get sub-5ms responses on every cached endpoint. Your database has headroom for the queries that actually matter — writes, aggregations, reports — instead of serving the same read results thousands of times per hour. And you did not write a connection pool manager, a TTL strategy, a cache invalidation system, or a fallback handler. You wrapped your route handler with cache.wrap() and moved on to the next feature.

Before: No Cache (85ms avg)

Request parsing
0.5 ms
DB: Product query
30 ms
DB: Variants query
22 ms
DB: Reviews query
28 ms
JSON serialization
4.5 ms
Total 85 ms

After: Cachee L1 (3ms avg)

Request parsing
0.5 ms
L1 cache lookup
0.002 ms
Response send
2.5 ms
Total 3 ms

Further Reading

Also Read

Add Caching in 5 Minutes. See 20x Improvement in 5 More.

Three lines of code. No Redis to manage. No TTLs to guess. Just wrap your route handler and watch response times drop.

Start Free Trial Schedule Demo