Redis Cache

intermediate*

Redis caching with Upstash. Includes cache utilities, automatic serialization, and cache invalidation patterns.

cachingredisupstashserverless
Tested on201619TS5.9
$ bunx sinew add caching/redis-cache
Interactive demo coming soon

1The Problem

Database queries and external API calls are slow and expensive:

  • Repeated queries for the same data
  • Cold cache after deployments
  • Manual cache invalidation is error-prone
  • No visibility into cache performance

2The Solution

Use Upstash Redis for serverless-compatible caching with automatic serialization.

3Files

lib/cache.ts

lib/cache.tsTypeScript
import { Redis } from "@upstash/redis";

export const redis = Redis.fromEnv();

// Generic cache helper with automatic serialization
export async function cache<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: { ttl?: number; tags?: string[] } = {}
): Promise<T> {
  const { ttl = 3600, tags = [] } = options;

  // Try to get from cache
  const cached = await redis.get<T>(key);
  if (cached !== null) {
    return cached;
  }

  // Fetch fresh data
  const data = await fetcher();

  // Store in cache
  await redis.set(key, data, { ex: ttl });

  // Track tags for invalidation
  for (const tag of tags) {
    await redis.sadd(`tag:${tag}`, key);
  }

  return data;
}

// Invalidate by key
export async function invalidate(key: string): Promise<void> {
  await redis.del(key);
}

// Invalidate by tag
export async function invalidateTag(tag: string): Promise<void> {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    await redis.del(...keys);
    await redis.del(`tag:${tag}`);
  }
}

lib/cached-queries.ts

lib/cached-queries.tsTypeScript
import { cache, invalidateTag } from "./cache";
import { prisma } from "./db";

// Cache a user query
export async function getUser(id: string) {
  return cache(`user:${id}`, () => prisma.user.findUnique({ where: { id } }), {
    ttl: 3600,
    tags: ["users", `user:${id}`],
  });
}

// Cache a list query
export async function getProducts() {
  return cache("products:all", () => prisma.product.findMany({ orderBy: { createdAt: "desc" } }), {
    ttl: 300,
    tags: ["products"],
  });
}

// Invalidate when data changes
export async function updateUser(id: string, data: { name: string }) {
  const user = await prisma.user.update({ where: { id }, data });
  await invalidateTag(`user:${id}`);
  return user;
}

export async function createProduct(data: { name: string; price: number }) {
  const product = await prisma.product.create({ data });
  await invalidateTag("products");
  return product;
}

app/api/users/[id]/route.ts

app/api/users/[id]/route.tsTypeScript
import { NextResponse } from "next/server";
import { getUser, updateUser } from "@/lib/cached-queries";

export async function GET(request: Request, { params }: { params: { id: string } }) {
  const user = await getUser(params.id);

  if (!user) {
    return NextResponse.json({ error: "User not found" }, { status: 404 });
  }

  return NextResponse.json(user);
}

export async function PATCH(request: Request, { params }: { params: { id: string } }) {
  const data = await request.json();
  const user = await updateUser(params.id, data);
  return NextResponse.json(user);
}

lib/cache-metrics.ts

lib/cache-metrics.tsTypeScript
import { redis } from "./cache";

// Track cache hits/misses
export async function trackCacheAccess(key: string, hit: boolean) {
  const metric = hit ? "cache:hits" : "cache:misses";
  await redis.incr(metric);
}

// Get cache statistics
export async function getCacheStats() {
  const [hits, misses] = await Promise.all([
    redis.get<number>("cache:hits") ?? 0,
    redis.get<number>("cache:misses") ?? 0,
  ]);

  const total = hits + misses;
  const hitRate = total > 0 ? (hits / total) * 100 : 0;

  return { hits, misses, total, hitRate: hitRate.toFixed(2) };
}

.env.example

.env.exampleBash
UPSTASH_REDIS_REST_URL="https://..."
UPSTASH_REDIS_REST_TOKEN="..."

4Dependencies

$ bun add @upstash/redis

5Configuration

TTL Strategies

// Short TTL for frequently changing data
{
  ttl: 60;
} // 1 minute

// Medium TTL for semi-static data
{
  ttl: 3600;
} // 1 hour

// Long TTL for rarely changing data
{
  ttl: 86400;
} // 24 hours

// No expiration (manual invalidation only)
{
  ttl: 0;
}
TypeScript

Stale-While-Revalidate Pattern

async function cacheWithSWR<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number,
  staleTime: number
): Promise<T> {
  const cached = await redis.get<{ data: T; timestamp: number }>(key);

  if (cached) {
    const age = Date.now() - cached.timestamp;

    // Return stale data and revalidate in background
    if (age > staleTime) {
      fetcher().then(async (data) => {
        await redis.set(key, { data, timestamp: Date.now() }, { ex: ttl });
      });
    }

    return cached.data;
  }

  const data = await fetcher();
  await redis.set(key, { data, timestamp: Date.now() }, { ex: ttl });
  return data;
}
TypeScript

6Usage

Basic Caching

import { cache } from "@/lib/cache";

const user = await cache(`user:${userId}`, () => fetchUserFromDatabase(userId), { ttl: 3600 });
TypeScript

Cache Invalidation on Mutation

import { invalidateTag } from "@/lib/cache";

async function updateProduct(id: string, data: ProductData) {
  await prisma.product.update({ where: { id }, data });
  await invalidateTag("products");
  await invalidateTag(`product:${id}`);
}
TypeScript

7Troubleshooting

Cache not invalidating

  • Check that tags are being set correctly
  • Verify the invalidation is being called after the database update
  • Use Redis CLI to inspect keys: redis-cli KEYS "tag:*"

Stale data issues

  • Reduce TTL for frequently changing data
  • Implement cache versioning for breaking changes
  • Use stale-while-revalidate for better UX

Related patterns