Redis caching is one of the highest-leverage performance improvements available — a hot cache turns a 50 ms database query into a sub-millisecond memory lookup. In 2026, the tooling splits into two clear camps: ioredis for traditional server deployments, and Upstash for serverless/edge runtimes where persistent TCP connections are not available.
What changed in 2026
- Upstash Redis became the standard for serverless caching — HTTP-over-TLS, globally distributed, and free tier covers most hobby projects.
ioredis v6 is stable; node-redis v4 is the alternative; both are actively maintained.
- Next.js App Router has a built-in
unstable_cache function that integrates well with Redis-backed stores.
- Dragonfly and KeyDB are Redis-compatible alternatives that offer better multi-threaded throughput on large machines — worth evaluating at high scale.
Basic setup with ioredis
npm install ioredis
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
redis.on('error', (err) => console.error('Redis error:', err));
Cache-aside pattern
async function getUser(userId: string) {
const cacheKey = `user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id: userId } });
if (user) {
await redis.setex(cacheKey, 3600, JSON.stringify(user)); // TTL: 1 hour
}
return user;
}
setex(key, seconds, value) atomically sets the value and TTL in one command.
Invalidation on write
async function updateUser(userId: string, data: Partial<User>) {
const updated = await db.user.update({ where: { id: userId }, data });
await redis.del(`user:${userId}`); // bust the cache on write
return updated;
}
For list caches (e.g., users:page:1), delete all affected keys or use tag-based invalidation.
Tag-based invalidation
async function setWithTag(key: string, value: unknown, ttl: number, tag: string) {
const pipeline = redis.pipeline();
pipeline.setex(key, ttl, JSON.stringify(value));
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttl);
await pipeline.exec();
}
async function invalidateTag(tag: string) {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length > 0) await redis.del(...keys);
await redis.del(`tag:${tag}`);
}
// Usage
await setWithTag('users:list', users, 3600, 'users');
// On any user mutation:
await invalidateTag('users');
Upstash for serverless / edge
npm install @upstash/redis
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv(); // reads UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN
export async function getCached<T>(key: string, fetcher: () => Promise<T>, ttl = 3600): Promise<T> {
const cached = await redis.get<T>(key);
if (cached !== null) return cached;
const fresh = await fetcher();
await redis.setex(key, ttl, fresh);
return fresh;
}
No connection pooling needed — each call is an independent HTTPS request. Works on Cloudflare Workers, Vercel Edge, and Next.js Middleware.
TTL strategy
| Data type |
Recommended TTL |
Notes |
| User profile |
1–4 hours |
Invalidate on update |
| Session data |
30 min (rolling) |
Extend on each access |
| API response (external) |
5–60 min |
Depends on data freshness requirements |
| Computed/aggregated stats |
1–24 hours |
Recompute on cron |
| Search results |
10–60 min |
Tolerate slight staleness |
| Config / feature flags |
5–15 min |
Fast-refresh critical |
How to pick a caching strategy
- Single server, persistent connections →
ioredis or node-redis with connection pool.
- Serverless / edge (Vercel, Cloudflare) → Upstash Redis.
- Need per-request cache + Redis → combine Next.js
unstable_cache with Upstash.
- Large key space with grouping → tag-based invalidation.
- Write-heavy data → cache only final aggregates, not raw rows.
Common mistakes
No TTL on cached keys. Memory fills up; Redis starts evicting random keys in allkeys-lru mode or errors in noeviction mode. Always set a TTL.
Caching mutable data without invalidation. The classic stale cache bug: user changes their name, the old name shows for an hour. Bust the cache synchronously on write.
JSON serializing inside the cached function on every call. Serialize once before storing; deserialize once after fetching. Avoid re-serializing hot-path objects.
Opening a new Redis connection per request in serverless. In Next.js API Routes (not edge), reuse a module-level connection or use Upstash. A new ioredis connection per request will exhaust file descriptors quickly.
What to skip
- Caching every DB query — cache reads that are slow or called at high frequency. Caching a 1 ms query that runs once per hour wastes memory.
- Redis as a primary database — it is an excellent cache and message broker; as a primary data store it requires careful persistence configuration and backup discipline.
- Large objects in Redis — values over ~1 MB stress serialization. Cache references (IDs) and let the client do a second cheap lookup if needed.
FAQ
Should I use get/set or hget/hset?
Use get/set with JSON for most objects — simpler, and most ORMs return plain objects. Use hashes only when you need to update individual fields without reading the full object.
How do I handle cache stampedes (thundering herd)?
Use a per-key lock (Redis SET key value NX PX timeout) to ensure only one request populates the cache on a miss while others wait or serve stale.
How much memory does Redis need?
A 512 MB Redis instance handles millions of small keys comfortably. Monitor used_memory_human and set maxmemory with allkeys-lru eviction policy to stay safe.
Is Upstash free?
The free tier allows 10,000 commands/day. Paid plans start at ~$0.20 per 100K commands. For most hobby projects, the free tier is sufficient.
Where to go next