TL;DR
Most early-stage SaaS doesn't need Redis. PostgreSQL handles 90% of caching use cases, Next.js has built-in request memoization, and premature caching adds complexity without benefit. Add Redis when: you need rate limiting, session-level caching in serverless, or background job queues. Upstash is the default serverless Redis for Vercel-hosted apps.
What Caching Actually Solves
Caching solves three distinct problems — each with different solutions:
| Problem | Without Cache | With Cache |
|---|---|---|
| Repeated expensive DB queries | 200ms per request | 2ms (cache hit) |
| Rate limiting across serverless functions | Impossible (no shared state) | Redis shared counter |
| Session data in stateless functions | Must re-query DB | Redis session store |
| Background job queues | No queuing | Redis-backed BullMQ |
The Serverless Caching Problem
Serverless functions (Vercel, Cloudflare Workers) don't share memory between invocations:
// ❌ This doesn't work in serverless
const cache = new Map<string, any>(); // Wiped on every cold start
export async function GET(req: Request) {
const cacheKey = 'popular-posts';
if (cache.has(cacheKey)) return Response.json(cache.get(cacheKey));
const posts = await prisma.post.findMany({ take: 10, orderBy: { views: 'desc' } });
cache.set(cacheKey, posts);
return Response.json(posts);
}
// In serverless: cache is always empty. Every request hits the DB.
// ✅ Redis works across serverless instances
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export async function GET() {
const cached = await redis.get<Post[]>('popular-posts');
if (cached) return Response.json(cached);
const posts = await prisma.post.findMany({ take: 10, orderBy: { views: 'desc' } });
await redis.setex('popular-posts', 300, posts); // Cache 5 minutes
return Response.json(posts);
}
Upstash: Serverless Redis for Vercel
Upstash is the default Redis choice for Vercel-hosted apps. Unlike traditional Redis, Upstash uses HTTP (REST API) instead of a persistent TCP connection — making it work in edge/serverless environments.
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// Rate limiting — common in SaaS APIs
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
});
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
const { success, limit, reset, remaining } = await ratelimit.limit(ip);
if (!success) {
return Response.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
}
);
}
// Process request...
}
Upstash pricing:
- Free: 10k commands/day, 256MB
- Pay-as-you-go: $0.2 per 100k commands
- Most indie SaaS fits in the free tier indefinitely
Railway Redis: Persistent Option
For apps on Railway that need persistent Redis (background job queues, session storage):
// BullMQ worker — needs persistent Redis connection
import { Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';
const connection = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
const emailQueue = new Queue('emails', { connection });
const worker = new Worker(
'emails',
async (job) => {
await sendEmail(job.data);
},
{
connection,
concurrency: 10,
}
);
// Queue an email from API route
export async function POST(req: Request) {
const { userId, template } = await req.json();
await emailQueue.add('send', { userId, template }, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
});
return Response.json({ queued: true });
}
Railway Redis: ~$5/month for 512MB. Use when you need BullMQ workers.
Next.js Built-In Caching (No Redis Needed)
Next.js App Router has built-in request memoization and fetch caching that covers most use cases without Redis:
// Request memoization — same data fetched once per request
// Even if called from multiple components
async function getUser(userId: string) {
return prisma.user.findUnique({ where: { id: userId } });
}
// Page.tsx calls getUser(userId)
// Layout.tsx also calls getUser(userId)
// -> Only ONE database query (React's cache() deduplicates)
import { cache } from 'react';
const getCachedUser = cache(getUser); // Memoized for request lifecycle
// Full Route Cache — static pages cached automatically
// Revalidation on demand or by time
export async function GET() {
const posts = await prisma.post.findMany({ take: 10 });
return Response.json(posts, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
},
});
}
For server-rendered pages with data that changes infrequently, Next.js caching handles this better than Redis.
When You DON'T Need Redis
Most indie SaaS at early stage:
// PostgreSQL is fast enough for most queries
// Adding Redis for this is premature optimization:
const posts = await prisma.post.findMany({
where: { published: true },
take: 10,
orderBy: { createdAt: 'desc' },
});
// This query takes 5-20ms with proper indexes
// Redis would save 4-18ms — not worth the complexity
// Add an index instead:
// @@index([published, createdAt]) in Prisma schema
Don't add Redis until you have:
- Rate limiting requirements
- Background job queues (BullMQ)
- Session storage needs exceeding DB performance
- Caching needs that PostgreSQL and Next.js don't cover
- Actual performance problems (measured, not assumed)
Boilerplate Redis Support
| Boilerplate | Redis Included | Provider | Use Case |
|---|---|---|---|
| T3 Stack | ❌ | — | Add if needed |
| ShipFast | ✅ Optional | Upstash | Rate limiting |
| Supastarter | ✅ | Upstash | Rate limiting, cache |
| Makerkit | ✅ | Upstash | Cache, rate limiting |
| Epic Stack | ❌ | — | Explicit no-Redis stance |
Cache Invalidation Patterns
The hard part of caching isn't adding the cache — it's knowing when to invalidate it. The patterns that work in SaaS:
Time-based expiry (TTL): Simplest. Set a TTL when caching and accept that data is stale for up to that period. Right for data that changes infrequently and where staleness is acceptable: dashboard stats, public content, pricing tables.
// Cache pricing for 1 hour — acceptable staleness for pricing data
await redis.setex('pricing:plans', 3600, JSON.stringify(plans));
Event-based invalidation: Delete the cache key when the underlying data changes. More complex but keeps data fresh. Right for data that users modify and expect to see updated immediately.
// After updating a user's subscription, invalidate their cached profile
export async function updateSubscription(userId: string, plan: string) {
await db.user.update({ where: { id: userId }, data: { plan } });
// Invalidate all related cache keys
await redis.del(`user:${userId}:profile`);
await redis.del(`user:${userId}:permissions`);
}
Write-through: Update cache and database simultaneously. Ensures cache is always current, but doubles write latency. Only worth it for read-heavy data with high cache hit rate requirements.
For most SaaS use cases: TTL for slowly-changing data, event-based for user-owned data that must be fresh, write-through almost never.
Redis Data Structures for SaaS Use Cases
Redis isn't just a key-value cache. The data structures enable patterns that database queries handle poorly:
Sorted sets for leaderboards and feeds:
// Track user activity score
await redis.zadd('leaderboard:monthly', { score: points, member: userId });
// Get top 10 users
const topUsers = await redis.zrange('leaderboard:monthly', 0, 9, { rev: true, withScores: true });
Sets for deduplication:
// Ensure each user only processes a webhook event once
const key = `webhook:processed:${eventId}`;
const added = await redis.sadd(key, '1');
await redis.expire(key, 86400); // 24 hours
if (added === 0) {
// Already processed this event — idempotency guard
return Response.json({ skipped: true });
}
Hash maps for session data:
// Store session data as a hash — update individual fields without re-serializing
await redis.hset(`session:${sessionId}`, {
userId: user.id,
organizationId: org.id,
plan: org.plan,
lastSeen: Date.now().toString(),
});
// Update a single field
await redis.hset(`session:${sessionId}`, 'lastSeen', Date.now().toString());
// Read specific fields
const { userId, plan } = await redis.hmget(`session:${sessionId}`, 'userId', 'plan');
Pub/Sub for real-time features: Redis pub/sub enables broadcasting events across multiple serverless instances — the missing piece for real-time features in stateless deployments.
Caching Decisions by Application Stage
The right caching strategy changes as your application scales:
0-1,000 users (early stage): No Redis. PostgreSQL handles all queries. Next.js request memoization covers same-request deduplication. Vercel's edge network caches static assets. Focus on building the product.
1,000-10,000 users: Add Upstash Redis for rate limiting (if you don't have it already, brute force attacks start at this scale). Next.js route caching for infrequently-changing API responses. Monitor slow queries with EXPLAIN ANALYZE.
10,000-100,000 users: Cache expensive, frequently-read queries (user profiles, permission lookups, pricing tables). Consider read replicas before Redis for read-heavy workloads. Background jobs need a proper queue (BullMQ with persistent Redis, not Upstash).
100,000+ users: Horizontal caching strategy. Cache layers at CDN edge, application layer, and database layer. Dedicated cache warming strategies. Cache hit rate monitoring as a production metric.
Most indie SaaS products never pass the first stage. Add caching only when you have measured, specific performance problems — not as a preemptive investment.
Measuring Before Caching
The most common mistake: adding Redis caching to queries that aren't actually the performance bottleneck. Before adding caching infrastructure, measure what's actually slow.
Use Prisma query logging in development:
const prisma = new PrismaClient({
log: [
{ level: 'query', emit: 'event' },
],
});
prisma.$on('query', (e) => {
if (e.duration > 100) {
console.warn(`Slow query (${e.duration}ms): ${e.query}`);
}
});
Use EXPLAIN ANALYZE on slow queries:
EXPLAIN ANALYZE
SELECT * FROM projects
WHERE organization_id = 'org_123'
ORDER BY created_at DESC
LIMIT 20;
If the output shows Seq Scan instead of Index Scan on a large table, adding an index solves the problem without Redis. A 5ms query becomes a 0.5ms query — caching it with Redis would save 4ms and add operational complexity. Adding the index is almost always the right first step.
Only after confirming that the query is properly indexed and still too slow does caching become worth the investment. In most cases, proper indexing reduces query time by 10-100x — far more than the 2-5x improvement caching provides, and without the operational complexity of a cache invalidation strategy.
Redis Session Storage: When It Makes Sense
Next.js default session management uses JWTs stored in cookies — no server-side storage, no Redis needed. When does Redis session storage become necessary?
Forced logout: JWTs can't be invalidated server-side. If you need the ability to immediately invalidate a user's session (compliance requirement, security incident, user-initiated logout from all devices), Redis session storage is required.
Real-time presence: Tracking which users are currently online requires server-side session tracking. Redis' built-in expiry makes it natural for presence: set a session key with a 30-second TTL, have clients refresh it every 20 seconds.
Large session payloads: If your session needs to carry more than a few kilobytes of data (loaded permissions, cached user preferences), a Redis-backed session is faster to look up than re-querying the database on every request.
For most SaaS products, JWT cookies with a database call for permission checks are adequate. The JWT approach scales to high concurrency naturally — no Redis connection pool to manage, no additional service to maintain. Add Redis sessions when you hit one of the specific limitations listed above.
Upstash vs Self-Hosted Redis: The 2026 Decision
Upstash and traditional Redis differ in architecture in a way that matters for your use case.
Upstash Redis uses HTTP/REST for connections instead of TCP. This means it works everywhere that HTTP works — Vercel Edge Functions, Cloudflare Workers, AWS Lambda — without managing a persistent connection pool. The trade-off: higher latency per command (HTTP overhead vs TCP direct), lower maximum throughput, and slightly higher cost per command at scale.
Self-hosted Redis (Railway, Fly.io Redis, Elasticache, Upstash-managed but with TCP) uses TCP connections and delivers 10-50x lower latency per command. Required for BullMQ background job workers (which use long-lived connections) and real-time applications where per-command latency matters.
The practical guide: use Upstash for rate limiting, simple caching, and any serverless context. Use TCP Redis (Railway, Fly) for background job queues with BullMQ or high-throughput caching where command latency matters.
For most indie SaaS on Vercel: start with Upstash (free tier, no infrastructure management). Add Railway Redis when you need persistent job queues or your Upstash command count grows beyond the free tier.
Cost Expectations
At early stage, Redis adds minimal cost:
| Use Case | Provider | Monthly Cost |
|---|---|---|
| Rate limiting + light caching | Upstash (free tier) | $0 |
| Rate limiting + moderate caching | Upstash pay-as-you-go | $2-15 |
| Background job queue (BullMQ) | Railway Redis 512MB | $5 |
| High-traffic caching | Redis Cloud 1GB | $20-50 |
For a SaaS doing fewer than 300,000 Redis commands per day (generous estimate for rate limiting + session cache at 10,000 MAU), Upstash's free tier covers everything. This is the typical profile for an early-stage B2B SaaS. Plan for Redis infrastructure costs to be negligible until you reach meaningful traffic scale. At that point, the value Redis provides through reduced database load and improved response times more than justifies the ongoing cost. Upstash's pricing model has remained stable through 2025, making it a reliable foundation for serverless caching without the risk of unexpected cost increases at growth inflection points.
The boilerplate and tool choices covered here represent the most actively maintained options in their category as of 2026. Evaluate each against your specific requirements: team expertise, deployment infrastructure, budget, and the features your product requires on day one versus those you can add incrementally. The best starting point is the one that lets your team ship the first version of your product fastest, with the least architectural debt.
Find boilerplates with caching and Redis setup in the StarterPick directory.
See our guide to SaaS observability for monitoring cache hit rates and database query performance.
Compare boilerplate infrastructure choices in the full-stack TypeScript boilerplates guide.
See how background job queues use the same Redis infrastructure in our webhook and background job guide.