Redis Caching: Speed Up Your App in Practice
Redis caching strategies, data structures, and cache invalidation with Node.js, Next.js, and Upstash integration guide.

Why your app feels slow, and how caching helps
Here's a failure pattern almost every growing app hits. The product detail endpoint, the page every user opens before adding something to the cart, works fine at low traffic. Then a marketing campaign brings in 10x the normal load and the database starts drowning. Queries that usually take 20ms pile up behind an exhausted connection pool and climb to 200ms, then 800ms, then seconds. Users bounce and revenue drops.
The fix often isn't rewriting queries or throwing hardware at it. A Redis caching layer in front of the few heaviest queries, with sensible TTLs, can bring response times back under 100ms in an afternoon of work.
Some numbers for context. A typical database query to PostgreSQL or MySQL takes somewhere between 1 to 50 milliseconds depending on complexity. A Redis lookup takes about 0.1 to 0.5 milliseconds. That's a 10x to 100x gap, and when your API chains several queries per request, those milliseconds add up fast.
What most tutorials skip is that "put data in Redis, read data from Redis" is the easy part. The hard part is knowing when to cache, what to cache, and above all when to throw the cache away. Cache invalidation, as Phil Karlton famously said, is one of the two hardest problems in computer science (the other being naming things).
This is a practical, opinionated guide to using Redis seriously: data structures, strategies, integration patterns, and the gotchas that bite in production, with the theory kept to what you need.
Understanding Redis data structures
A lot of developers think of Redis as a plain key-value store, and I think that's why so many underuse it. It's a data structure server, and choosing the right structure for the job matters a lot.
Strings
The simplest structure: a value stored against a key. Most people hear "Redis" and stop here, which is a shame.
SET user:1001:name "Anurag Sharma"
GET user:1001:name
# "Anurag Sharma"
# With expiry
SET session:abc123 "{\"userId\":1001}" EX 3600
Use strings for session data, simple cached responses, counters (with INCR and DECR), and rate limiting tokens.
Hashes
Hashes behave like objects. Instead of serializing a whole user object into a JSON string, you store each field separately.
HSET user:1001 name "Anurag" email "anurag@example.com" role "admin"
HGET user:1001 name
# "Anurag"
HGETALL user:1001
# name, Anurag, email, anurag@example.com, role, admin
The benefit is that you can update one field without fetching and re-serializing the whole object. If your user object has 20 fields and you only need to touch the lastLogin timestamp, a hash saves bandwidth and processing time. It's a small saving per call, but you make that call a lot.
Lists
Lists are ordered collections, a good fit for message queues, activity feeds and recent items. They're a natural fit for notification systems.
LPUSH notifications:1001 "New comment on your post"
LPUSH notifications:1001 "Someone liked your photo"
LRANGE notifications:1001 0 9
# Returns the 10 most recent notifications
Sets
Sets hold unique elements with no order. Typical uses are unique visitors, tags and mutual friends.
SADD online_users "user:1001" "user:1002" "user:1003"
SISMEMBER online_users "user:1001"
# 1 (true)
SCARD online_users
# 3
Sorted sets
Like sets, except every element has a score. This is where Redis is at its best: leaderboards, priority queues and time-series data become very simple to build.
ZADD leaderboard 1500 "player:anurag" 2200 "player:priya" 1800 "player:rajesh"
ZREVRANGE leaderboard 0 2 WITHSCORES
# player:priya, 2200, player:rajesh, 1800, player:anurag, 1500
| Data Structure | Best For | Time Complexity (Common Ops) |
|---|---|---|
| Strings | Sessions, counters, simple cache | O(1) |
| Hashes | User profiles, object fields | O(1) per field |
| Lists | Queues, activity feeds | O(1) push/pop, O(N) range |
| Sets | Unique tracking, tags | O(1) add/check |
| Sorted Sets | Leaderboards, rankings | O(log N) add, O(log N + M) range |
Caching strategies that work
Which pattern you pick depends on your read-to-write ratio, your consistency needs, and how angry your users get when they see stale data. Some users won't notice. Others will file bug reports within seconds.
Cache-aside (lazy loading)
This is the most common pattern, and it should be your default unless you have a specific reason to pick something else.
The flow:
- Application receives a request
- Check Redis for the data
- If found (cache hit), return it
- If not found (cache miss), query the database
- Store the result in Redis with a TTL
- Return the data
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// Step 1: Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Step 2: Query database
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// Step 3: Populate cache with 1-hour TTL
await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}
On the plus side, it only caches data somebody requested, and it's simple to implement. Cache misses are handled gracefully too. The app doesn't break; that first request is just a bit slower.
The downsides are that the first request is always slow (cold cache), and you can get stale data if someone updates the database directly and skips your application layer.
Write-Through
Every write goes to the cache and the database at the same time, so the cache is always up to date.
async function updateUser(userId, data) {
const cacheKey = `user:${userId}`;
// Write to database
await db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, userId]);
// Write to cache
await redis.set(cacheKey, JSON.stringify(data), 'EX', 3600);
}
The good part: the cache is never stale (as long as every write goes through your application), and reads are fast from the very first request after a write.
The cost is higher write latency, since you're writing to two places. You may also end up caching data that's rarely read, which wastes memory on things nobody asks for.
Write-behind (write-back)
The application writes to Redis first, and a background process syncs to the database asynchronously. Writes are as fast as they get, but the complexity goes up, and by a lot.
I wouldn't recommend it for most applications. If Redis crashes before the data reaches the database, those writes are lost. For high-throughput cases like analytics counters or view counts, though, it makes perfect sense, because losing a few page view counts during a server restart probably doesn't matter to you. In general I think most teams overcomplicate their caching setup when cache-aside would have been fine.
TTL and eviction: when data should expire
Give every cached value a Time To Live (TTL), every one of them. Keys without TTLs are a memory leak you're building on purpose.
SET product:5001 "{...}" EX 1800 # Expires in 30 minutes
TTL product:5001 # Check remaining time
These are sensible starting TTL values, and most production systems land in similar ranges:
- User sessions: 24 hours (or match your session cookie expiry)
- Product listings: 5 to 15 minutes (prices change, stock changes)
- Blog posts or static content: 1 to 6 hours
- Configuration/feature flags: 1 to 5 minutes (short TTL so changes propagate fast)
- API rate limits: window duration (60 seconds for per-minute limits)
Eviction policies
When Redis runs out of memory, it needs to decide what to throw away. You configure this with maxmemory-policy.
| Policy | Behavior | Best For |
|---|---|---|
noeviction | Returns error on writes | When data loss is unacceptable |
allkeys-lru | Evicts least recently used keys | General-purpose caching |
allkeys-lfu | Evicts least frequently used keys | When hot data matters most |
volatile-lru | LRU among keys with TTL set | Mixed persistent + cached data |
volatile-ttl | Evicts keys closest to expiration | When TTL reflects importance |
For most caching use cases, allkeys-lru is the right default. It keeps frequently accessed data warm and drops old stuff on its own. Don't overthink it: pick allkeys-lru, watch your hit rates, and change it only if the numbers tell you to.
Cache invalidation: the hard part
You've probably heard the joke. Two hard problems in computer science: cache invalidation, naming things, and off-by-one errors. It stops being funny around 2 AM when users are looking at stale prices, outdated inventory counts or yesterday's profile picture.
Pattern 1: TTL-based expiry
The simplest approach is to set a TTL and accept that data can be stale for that long. Often that's perfectly fine. If a blog post stays cached for 10 minutes after an edit, does anyone care? Probably not. Your blog isn't stock trading.
Pattern 2: Explicit invalidation
When a write happens, explicitly delete or update the cached value.
async function updateProduct(productId, data) {
await db.query('UPDATE products SET price = $1 WHERE id = $2', [data.price, productId]);
// Delete the cache entry — next read will fetch fresh data
await redis.del(`product:${productId}`);
// Also invalidate any list caches that might contain this product
await redis.del('products:featured');
await redis.del(`products:category:${data.categoryId}`);
}
This is where it gets tricky, because you have to know every cache key that might hold stale data. A product can be cached on its own, inside a category listing, in search results and in a "featured products" list. Miss one and the data is inconsistent; miss several and your support team starts getting tickets. My guess is that most production caching bugs come from incomplete invalidation, more than from bad TTLs.
Pattern 3: Cache tags / namespacing
Put a version number or timestamp in your cache keys. To invalidate, you bump the version.
async function getProductCacheVersion() {
return await redis.get('products:version') || '1';
}
async function getProduct(productId) {
const version = await getProductCacheVersion();
const cacheKey = `product:v${version}:${productId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const product = await db.query('SELECT * FROM products WHERE id = $1', [productId]);
await redis.set(cacheKey, JSON.stringify(product), 'EX', 3600);
return product;
}
async function invalidateAllProducts() {
await redis.incr('products:version');
// Old keys will expire naturally via TTL
}
It's neat, but wasteful: the old cached data hangs around eating memory until its TTL runs out. That's fine when memory isn't tight, and a problem on a 256 MB free-tier Redis instance.
Redis with Node.js and Express
This is a production-ready setup using ioredis, which in my experience is the best Redis client for Node.js. The official redis package (node-redis, which Redis now recommends) has improved a lot recently, but ioredis still has better cluster support and Lua scripting.
import Redis from 'ioredis';
import express from 'express';
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: 6379,
password: process.env.REDIS_PASSWORD,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
});
redis.on('error', (err) => console.error('Redis connection error:', err));
redis.on('connect', () => console.log('Connected to Redis'));
const app = express();
// Caching middleware
function cacheMiddleware(ttl = 300) {
return async (req, res, next) => {
const key = `cache:${req.originalUrl}`;
try {
const cached = await redis.get(key);
if (cached) {
return res.json(JSON.parse(cached));
}
} catch (err) {
console.error('Cache read error:', err);
// Fall through to handler — cache failures should not break your app
}
// Override res.json to intercept the response
const originalJson = res.json.bind(res);
res.json = (data) => {
redis.set(key, JSON.stringify(data), 'EX', ttl).catch(console.error);
return originalJson(data);
};
next();
};
}
app.get('/api/products', cacheMiddleware(600), async (req, res) => {
const products = await db.query('SELECT * FROM products WHERE active = true');
res.json(products);
});
I've seen this mistake in production too many times, so I'll spell it out: never let a cache failure crash your application. If Redis goes down, your app should get slower and keep working. Wrap Redis calls in try-catch blocks and fall through to the database on errors, because the cache is an optimization your app should be able to run without.
Redis with Next.js
Next.js has its own caching mechanisms (ISR, fetch cache, Data Cache), but Redis gives you more control, especially in API routes and server actions. I reach for it when I need precise TTLs or want to invalidate specific keys on demand.
// lib/redis.js
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export async function getCached(key, fetcher, ttl = 300) {
try {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
} catch {
// Silently fail — database is the source of truth
}
const data = await fetcher();
try {
await redis.set(key, JSON.stringify(data), 'EX', ttl);
} catch {
// Log but do not throw
}
return data;
}
// app/api/products/route.js
import { getCached } from '@/lib/redis';
import { db } from '@/lib/db';
export async function GET() {
const products = await getCached(
'products:all',
() => db.select().from(products).where(eq(products.active, true)),
600
);
return Response.json(products);
}
It's short, and it falls back gracefully when Redis is unavailable, which is why I keep coming back to it.
Pub/Sub for real-time features
Redis also does more than caching. Its publish/subscribe system is surprisingly useful for real-time notifications, chat apps and event broadcasting, and most people only find out about it when they need it.
// Publisher (when something happens)
async function publishNotification(userId, message) {
await redis.publish(`notifications:${userId}`, JSON.stringify({
message,
timestamp: Date.now(),
}));
}
// Subscriber (listening for events)
const subscriber = new Redis(process.env.REDIS_URL);
subscriber.subscribe('notifications:1001', (err, count) => {
if (err) console.error('Subscribe error:', err);
console.log(`Subscribed to ${count} channels`);
});
subscriber.on('message', (channel, message) => {
const data = JSON.parse(message);
// Send to WebSocket client, trigger UI update, etc.
console.log(`Received on ${channel}:`, data);
});
Pub/Sub is fire-and-forget. If no subscriber is listening when a message is published, that message is lost for good. For durable messaging where you can't afford to drop events, use Redis Streams, which come with different guarantees.
Redis Stack: JSON and search
Older plain Redis required you to serialize everything to strings. Redis Stack added native JSON support and full-text search, and since Redis 8 those features ship in Redis Open Source itself, which eliminates a lot of boilerplate code.
# Store a JSON document
JSON.SET product:5001 $ '{"name":"MacBook Air M4","price":114900,"category":"laptops","tags":["apple","ultrabook"]}'
# Query nested fields
JSON.GET product:5001 $.name
# "MacBook Air M4"
# Update a single field
JSON.SET product:5001 $.price 109900
The Search module lets you create indexes and run queries without running a separate search engine like Elasticsearch:
# Create an index
FT.CREATE idx:products ON JSON PREFIX 1 product: SCHEMA
$.name AS name TEXT
$.price AS price NUMERIC
$.category AS category TAG
# Search
FT.SEARCH idx:products "@category:{laptops} @price:[50000 120000]"
For small to medium datasets (under a few hundred thousand documents), Redis Search holds its own against Elasticsearch and is far simpler to operate. It may not scale to millions of documents as gracefully, but for most applications it's more than enough.
Upstash: Redis for serverless
On Vercel, Cloudflare Workers or any other serverless platform, traditional Redis struggles. Long-lived TCP connections and serverless cold starts are a bad mix: connections pile up, timeouts happen, and it gets messy.
Upstash solves this with an HTTP-based Redis client. Every command is an HTTP request, so you don't need persistent connections.
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
// Works exactly like regular Redis, but over HTTP
await redis.set('greeting', 'hello', { ex: 3600 });
const value = await redis.get('greeting');
The free tier is generous: 500K commands per month with 256 MB storage. Side projects and small apps may never need to pay. Beyond the free tier, pricing is pay-per-request, which suits how serverless billing works anyway.
Upstash is a popular choice for Next.js projects on Vercel. If its nearest region to you is outside India, expect noticeably higher latency than a local instance, though that's acceptable for most caching scenarios. With zero ops work to do, it's a good trade for most side projects.
Monitoring with RedisInsight
You can't fix what you can't see. RedisInsight is Redis's official GUI tool, it's free, and it gives you:
- Real-time memory analysis, showing which keys eat the most memory
- A slow log viewer for commands taking longer than expected
- A key browser to inspect your data visually
- CLI integration, so you can run commands with autocompletion
- Profiling, to watch commands in real-time
Install it from the Redis website or run it as a Docker container:
docker run -d --name redisinsight -p 5540:5540 redis/redisinsight:latest
The metrics I keep an eye on:
- Hit rate: ideally above 90%. If it's below 80%, your caching strategy needs work, so go and investigate.
- Memory usage: set alerts before you hit
maxmemory. OOM errors at 3 AM are no fun. - Connected clients: a sudden spike can point to connection leaks in your application code.
- Eviction count: if keys get evicted often, you need more memory or shorter TTLs, quite possibly both.
Common mistakes and how to avoid them
These are the Redis mistakes that cause the most grief in production.
-
Caching everything. Plenty of data gains nothing from a cache. If a query is already fast and the data changes often, managing the cache can cost more than it saves. Profile first, then cache.
-
No TTL on keys. I said this earlier and I'm repeating it because I keep seeing it: every cached key needs an expiration, with no exceptions.
-
Giant serialized objects. If you're caching a 500 KB JSON blob, something is probably off. Cache smaller, more targeted pieces, and your network, memory and response times all benefit.
-
Not handling Redis failures gracefully. Redis is your cache, and your database is still the source of truth. If Redis goes down, the app should slow down and keep running on a fallback path to the database. I can't stress this enough.
-
Using the KEYS command in production. Oh boy. The
KEYSpattern command scans every key in the database and blocks the server while it does. On an instance with millions of keys that can freeze everything for seconds. UseSCANinstead.
// BAD — blocks Redis
const allKeys = await redis.keys('user:*');
// GOOD — iterates without blocking
let cursor = '0';
const keys = [];
do {
const [newCursor, batch] = await redis.scan(cursor, 'MATCH', 'user:*', 'COUNT', 100);
cursor = newCursor;
keys.push(...batch);
} while (cursor !== '0');
- Ignoring connection pooling. One Redis connection handles a lot of throughput thanks to pipelining, but under high concurrency you should pool connections or at least reuse a shared client.
ioredisdoesn't ship a connection pool; itsClusterandSentinelmodes are for connecting to those topologies, so reach for a pool library or one long-lived client per process.
When not to use Redis
I love Redis, but it doesn't solve everything, and I suspect some teams adopt it without needing it. Skip Redis if:
- Your dataset fits entirely in application memory and you've only got one server (use an in-process cache like
node-cacheorlru-cacheinstead) - You need complex queries across cached data (use a database with proper indexing)
- You need strong consistency guarantees (Redis replication is asynchronous by default)
- Your data is write-heavy with few reads (caching helps read performance, not write performance)
Wrapping up
Redis caching comes down to a few rules: cache what's read often and changes rarely, always set TTLs, handle failures gracefully, and measure your hit rates. They're easy to say and surprisingly hard to get right.
Start with cache-aside on your slowest endpoints, measure the improvement, and expand from there. If you try to cache everything on day one, you'll end up chasing stale data bugs at 2 AM and questioning your career choices.
If you're building on serverless, Upstash makes the infrastructure side trivial. For traditional servers, a managed Redis instance from your cloud provider (ElastiCache on AWS, Memorystore on GCP) saves you from operational headaches. If you're new to containerized deployments, our Docker and Kubernetes guide covers how to run Redis in Docker, which is the easiest way to get started locally. And if you're preparing for technical interviews, caching is a core component of every system design interview.
Either way, Redis gives you a lot back for the effort it takes to set up. A few hours of work can completely change how your application performs.
And yes, cache invalidation really is that hard. It's a joke and it also isn't, and you'll see what I mean the first time a stale cache bug hits production at the worst possible moment. It happens to everyone eventually.
Anurag Sinha
Founder & Editor
Founder and editor of Tech Tips India. Writes tech guides, buying guides and explainers for Indian readers, with rupee pricing and local availability. Product roundups are researched from manufacturer specs and published reviews, not lab testing.
Stay Ahead in Tech
Get the latest tech news, tutorials, and reviews delivered straight to your inbox every week.
No spam ever. Unsubscribe anytime.
Comments (0)
Leave a Comment
All comments are moderated before appearing. Please be respectful and follow our community guidelines.
Related Articles

API Design: REST and GraphQL Patterns That Scale
API design guide covering REST, GraphQL, tRPC, authentication, rate limiting, error handling, and testing with Node.js examples.

DSA Roadmap for Placements: What Matters in 2026
Practical DSA roadmap for campus placements in India: topic priorities, platform choices, company patterns, and time management.

TypeScript Advanced Patterns You Should Know
Advanced TypeScript: discriminated unions, template literals, branded types, builder pattern, and Zod validation with examples.