This skill covers the three things serverless apps most often need Redis for:
caching, sessions, and rate limiting. The client talks to Redis over HTTP, so
it works where a long-lived TCP connection does not (edge middleware, short
lived functions). Follow the steps in order; each ends with a checkpoint.
Run checkpoints against a development or test database with its own
credentials, kept apart from production.
- An Upstash Redis database (hosted service; usage-based pricing with a free
tier). Credentials are a REST URL and token from the database page.
- Environment variables
UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
- Keep the token in server side environment variables only, out of client
bundles, logs and committed files. Use a separate database and token for each
environment, and rotate the token if it is exposed.
- Every command is an HTTP request. Batch with
pipeline() or MGET/MSET
when you issue many commands per request; avoid KEYS * in production.
- Values are serialized automatically (objects, arrays, numbers round-trip).
Do not
JSON.stringify before set or parseInt after get.
npm install @upstash/redis @upstash/ratelimit
Install the major versions listed under compatibility, commit the lockfile, and
review upgrades before applying them.
// lib/redis.ts
import { Redis } from "@upstash/redis";
// Reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN
export const redis = Redis.fromEnv();
Create the client at module scope, not inside the request handler, so
ephemeral caches and pipelines can be reused across invocations.
Checkpoint: await redis.ping() returns "PONG".
import { redis } from "@/lib/redis";
type User = { id: string; name: string; plan: "free" | "pro" };
export async function getUser(userId: string): Promise<User | null> {
const key = `user:${userId}`;
const cached = await redis.get<User>(key);
if (cached) return cached;
const user = await db.users.findById(userId); // your data source
if (user) await redis.set(key, user, { ex: 3600 }); // 1 hour TTL
return user;
}
export async function updateUser(userId: string, patch: Partial<User>) {
const user = await db.users.update(userId, patch);
await redis.del(`user:${userId}`); // invalidate
return user;
}
export async function deleteUser(userId: string) {
await db.users.delete(userId);
await redis.del(`user:${userId}`); // invalidate
}
Always set a TTL on cache entries; namespace keys (user:123, session:abc).
Validate ids before building keys: require a UUID format for session ids read
from cookies and the expected format for user ids, and reject anything else.
Cache only the fields the app needs; keep password hashes, tokens and secrets
out of the cache, and review data classification and region requirements before
storing personal data in the hosted database.
Checkpoint: second call to getUser returns without hitting the database
and await redis.ttl("user:123") is positive.
import { redis } from "@/lib/redis";
const SESSION_TTL = 60 * 60 * 24; // 24 hours
export async function createSession(userId: string, data: Record<string, unknown>) {
const sessionId = crypto.randomUUID();
await redis.set(`session:${sessionId}`, { ...data, userId, createdAt: Date.now() }, { ex: SESSION_TTL });
return sessionId;
}
export async function getSession<T = Record<string, unknown>>(sessionId: string) {
const session = await redis.get<T>(`session:${sessionId}`);
if (session) await redis.expire(`session:${sessionId}`, SESSION_TTL); // slide
return session;
}
export async function destroySession(sessionId: string) {
await redis.del(`session:${sessionId}`);
}
Store the session id in an HttpOnly; Secure; SameSite cookie; never put the
Redis token in client code.
Build the data argument from trusted, filtered values rather than raw request
input. Sliding expiration alone keeps an active session alive forever, so also
enforce a maximum total lifetime by checking the stored creation time and
destroying older sessions. Create a new session id at login or privilege change
and destroy the old one. The read and the expiry refresh are two round trips;
prefer a single command that reads and refreshes the expiry, or a pipeline().
Checkpoint: getSession after createSession returns the object with
userId; after destroySession it returns null.
// app/api/search/route.ts (Next.js App Router; same pattern for any fetch handler)
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 requests per 10 seconds
prefix: "ratelimit:search", // isolate keys per limiter
});
export async function POST(request: Request) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "anonymous";
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return new Response("Too Many Requests", {
status: 429,
headers: {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"Retry-After": String(Math.max(0, Math.ceil((reset - Date.now()) / 1000))),
},
});
}
// handle the request
return Response.json({ ok: true });
}
- Identifier: use the user id or API key when authenticated; fall back to IP.
The first forwarded value in the example is set by the client and can be
forged, so in production take the address from the platform's IP helper or
the value added by your own trusted proxy. Requests that lack a trusted
identifier all share one bucket; reject them or send them to a separate,
deliberately strict limiter with its own prefix.
- Failures: the limiter timeout option lets requests through when it fires, and
Redis errors can surface as server errors. Wrap the limit call in error
handling and choose deliberately between failing closed with a service
unavailable response and failing open with logging, based on how sensitive
the endpoint is.
- Algorithms:
Ratelimit.fixedWindow(n, "1 m") (cheapest), slidingWindow
(smooth boundaries, default choice), tokenBucket(refill, "10 s", max)
(allows bursts). Windows accept ms, s, m, h, d.
- Tiers: create one
Ratelimit per tier with different prefix values.
- Edge middleware / Cloudflare Workers with
analytics: true: the result has a
pending promise; pass it to context.waitUntil(pending) so background work
finishes before the runtime exits.
reset is a Unix timestamp in milliseconds.
Checkpoint: the 11th request within 10 seconds returns 429 with a
Retry-After header; after the window it succeeds again.
- Creating clients inside handlers: the limiter's in-memory
ephemeralCache only helps when the instance outlives the request.
- Manual JSON:
redis.set("k", JSON.stringify(v)) then redis.get returns
an already-parsed object; double parsing throws.
- No TTL on cache keys: memory grows until eviction; always pass
{ ex }.
- Trusting
x-forwarded-for blindly: the first hop is client supplied; use the
platform's IP helper, or the value your trusted proxy adds.
- Forgetting
pending on edge runtimes with analytics or multi-region
limiters.
- Long-running servers with a TCP Redis connection already in place: keep
ioredis/node-redis.
- Vector search or RAG: use a vector database skill instead.
- Sub-millisecond, in-process caching: use an in-memory LRU.