Redis Caching Strategies for High-Traffic CMS: Cache-Aside, Write-Through, and Invalidation Patterns

Buka terminal, bikin folder baru.

mkdir high-traffic-cms
cd high-traffic-cms

I'm using **Prisma** with PostgreSQL because the existing s already use Prisma heavily – but they didn't touch Redis caching at scale. I chose **ioredis** over node-redis because ioredis supports cluster mode and has better pub/sub performance. For this CMS, every article view hits the database – that's a problem when you get 10k concurrent reads. We'll layer Redis to cache the hot paths.

Client → Next.js API Route → Prisma → PostgreSQL
                ↓
            Redis Cache (get/set/expire)
                ↓
            Cache-Aside: check cache first, then DB

high-traffic-cms/
├── src/
│   ├── lib/
│   │   ├── prisma.ts
│   │   └── redis.ts
│   ├── app/
│   │   ├── api/
│   │   │   └── articles/
│   │   │       └── route.ts
│   │   └── page.tsx
│   └── types/
│       └── index.ts
├── prisma/
│   └── schema.prisma
├── package.json
└── tsconfig.json

Set up Node.js 20, Prisma, Redis.

npm init -y npm install next react react-dom prisma @prisma/client @prisma/adapter-pg npm install ioredis express rate-limiter-flexible

A. Prisma Schema

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  directUrl = env("DIRECT_URL") // for serverless
}

model Article {
  id        String   @id @default(uuid())
  title     String
  slug      String   @unique
  body      String
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

**B. Prisma Client (with adapter)**

// src/lib/prisma.ts import { PrismaClient } from '@/generated/prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter });

export default prisma;

C. Redis Client

// src/lib/redis.ts
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');

redis.on('error', (err) => console.error('Redis error:', err));

export default redis;

**D. Why these choices?**

- Using `@prisma/adapter-pg` because PostgreSQL is our primary DB and it's the supported way in Prisma 7.
- ioredis gives us built-in connection pooling and sentinel support –  that for high traffic.
- UUIDs over auto-increment: avoids collision when sharding later.

**E. The struggle**

Awalnya gue pake `@prisma/client` langsung, tapi error `Invalid provider`. Ternyata di Prisma 7, kita harus generate client ke folder `generated`. Jalanin `npx prisma generate --no-engine`? Gak, karena adapter pg butuh engine. Fix: `npx prisma generate` and then import from `@/generated/prisma/client`.

Buat Next.js API route untuk GET  by slug. Tanpa cache dulu – biar kita lihat baseline.

// src/app/api/articles/[slug]/route.ts import { NextRequest, NextResponse } from 'next/server'; import prisma from '@/lib/prisma';

export async function GET( request: NextRequest, { params }: { params: { slug: string } } ) { const { slug } = params;

const article = await prisma.article.findUnique({ where: { slug }, select: { id: true, title: true, body: true, createdAt: true }, });

if (!article) { return NextResponse.json({ error: 'Article not found' }, { status: 404 }); }

return NextResponse.json(article); }

Jalanin npm run dev dan hit http://localhost:3000/api/articles/hello-world. Kalau error karena belum ada data, insert manual via Prisma Studio (npx prisma studio).

Gue sengaja bikin error: kalo slug-nya gak ada, kita return 404. Tapi gak ada error handling for DB connection – nanti kita tambahin.

Strategy: cek Redis dulu, kalau ada return. Kalau gak ada, query DB, simpan ke Redis dengan TTL, lalu return.

// src/lib/cache.ts
export async function getOrSetCache<T>(
  key: string,
  fetchFn: () => Promise<T>,
  ttlSeconds = 3600
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached) as T;
  }
  const fresh = await fetchFn();
  await redis.set(key, JSON.stringify(fresh), 'EX', ttlSeconds);
  return fresh;
}

Trus kita pake di route:

// src/app/api/articles/[slug]/route.ts (updated) import { NextRequest, NextResponse } from 'next/server'; import prisma from '@/lib/prisma'; import redis from '@/lib/redis';

const CACHE_TTL = 3600; // 1 hour for article content

export async function GET( request: NextRequest, { params }: { params: { slug: string } } ) { const { slug } = params; const cacheKey = article:${slug}:detail;

try { const article = await getOrSetCache( cacheKey, async () => { const article = await prisma.article.findUnique({ where: { slug }, select: { id: true, title: true, body: true, createdAt: true }, }); if (!article) throw new NotFoundError('Article not found'); return article; }, CACHE_TTL );

return NextResponse.json(article); } catch (error) { if (error instanceof NotFoundError) { return NextResponse.json({ error: error.message }, { status: 404 }); } console.error('Failed to fetch article:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }

class NotFoundError extends Error { constructor(message: string) { super(message); this.name = 'NotFoundError'; } }

Kenapa pake custom NotFoundError? Biar kita bisa bedain error not found dari error database. Kalau error lainnya, kita log dan return 500.

Coba jalanin. Hit endpoint dua kali. Pertama lambat, kedua cepet karena dari cache. Check dengan redis-cli get article:hello-world:detail.

Saat kita update , kita harus update cache juga biar konsisten. Kita lakukan write-through: update DB, lalu update Redis.

// src/app/api/articles/[slug]/route.ts (add PATCH)
export async function PATCH(
  request: NextRequest,
  { params }: { params: { slug: string } }
) {
  const { slug } = params;
  const body = await request.json();
  const { title, body: articleBody } = body;

  try {
    const updated = await prisma.article.update({
      where: { slug },
      data: { title, body: articleBody },
    });

    // Write-through: update cache
    const cacheKey = `article:${slug}:detail`;
    await redis.set(cacheKey, JSON.stringify(updated), 'EX', CACHE_TTL);

    return NextResponse.json(updated);
  } catch (error) {
    console.error('Update failed:', error);
    return NextResponse.json({ error: 'Update failed' }, { status: 500 });
  }
}

**Tapi ada masalah**: kalau update gagal di DB, cache sudah diupdate? Enggak, karena kita update cache setelah sukses. Tapi kalau Redis down? Well, kita skip cache update – data di cache akan stale sampai TTL. This is a trade-off. For critical consistency, we could implement a two-phase commit, but overkill for this project.

When deleting an article, remove the cache key:

export async function DELETE( request: NextRequest, { params }: { params: { slug: string } } ) { const { slug } = params;

await prisma.article.delete({ where: { slug } }); const cacheKey = article:${slug}:detail; await redis.del(cacheKey);

return new NextResponse(null, { status: 204 }); }

What if we have multiple app instances? Each instance caches locally, but if one updates an article, other instances still serve stale cache. Redis Pub/Sub solves this:

// In redis.ts, add a subscriber
const subscriber = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');

subscriber.subscribe('cache-invalidation', (err, count) => {
  if (err) console.error('Subscriber error:', err);
  console.log(`Subscribed to ${count} channels`);
});

subscriber.on('message', (channel, message) => {
  if (channel === 'cache-invalidation') {
    const { key } = JSON.parse(message);
    redis.del(key);  // delete from the main redis client (same instance)
    console.log(`Invalidated key: ${key}`);
  }
});

Then in PATCH handler, after update, publish invalidation event:

await redis.publish('cache-invalidation', JSON.stringify({ key: cacheKey }));

Now all instances will invalidate that cache key, ensuring consistency.

The struggle: gue lupa bahwa subscriber harus redis client terpisah, karena redis client utama udah dipake buat operasi lain. Kalau pakai satu client untuk pub/sub, blocking. Pake dua koneksi – satu untuk operasi, satu untuk subscribe.

High-traffic API needs rate limiting. Use rate-limiter-flexible with Redis:

// src/lib/rateLimiter.ts
import { RateLimiterRedis } from 'rate-limiter-flexible';
import redis from './redis';

const rateLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'ratelimit',
  points: 10, // 10 requests
  duration: 1, // per second
});

export async function checkRateLimit(ip: string) {
  try {
    await rateLimiter.consume(ip);
  } catch {
    throw new RateLimitError('Too many requests');
  }
}

export class RateLimitError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'RateLimitError';
  }
}

Apply to GET route:

import { checkRateLimit, RateLimitError } from '@/lib/rateLimiter';

export async function GET(request: NextRequest, { params }: { params: { slug: string } }) { const ip = request.headers.get('x-forwarded-for') || 'unknown'; try { await checkRateLimit(ip); } catch (error) { if (error instanceof RateLimitError) { return NextResponse.json({ error: error.message }, { status: 429 }); } throw error; } // ... rest of handler }

Error handling for Redis connection failures: If Redis is down, we fall back to database. Wrap redis calls in try-catch and fallback.