Redis Caching for Bangkok Web Apps: Speed Up Your Next.js Site
16 May 2026 · by Yunmin Shin
Why Add Redis to a Next.js Application?
Next.js has built-in caching for fetch requests and page routes, but there's a category of problem that needs something outside the Next.js request lifecycle entirely:
- Caching an expensive availability query that dozens of visitors trigger at once — a clinic's booking page during a promotion, for instance
- Storing session data for logged-in staff on an admin dashboard
- Rate limiting a public API endpoint or a LINE webhook across serverless function instances that don't share memory
- Deduplicating expensive operations, like a Claude or OpenAI API call generating a booking confirmation summary, so the same request isn't paid for twice
Redis is the standard tool for all of this: an in-memory key-value store with sub-millisecond response times and data structures (strings, hashes, sorted sets) that map cleanly onto these problems.
Which Redis Service Should You Use?
Upstash is our default for Next.js and serverless deployments. It's HTTP-based rather than TCP-based, so it works correctly in serverless environments (Vercel, Cloudflare Workers) where holding a persistent TCP connection open across invocations isn't practical. Upstash has a generous free tier and a Singapore region, which keeps round-trip latency low for a site whose traffic is overwhelmingly from Thailand.
npm install @upstash/redis
Configure via environment variables:
UPSTASH_REDIS_REST_URL=https://...
UPSTASH_REDIS_REST_TOKEN=...
What Is the Cache-Aside Pattern? A Clinic Slot-Availability Example
Cache-aside is the pattern you'll reach for most often: check the cache first; on a hit, return immediately; on a miss, fetch from the database, populate the cache, and return.
A clinic's appointment booking widget is a good real example. Every visitor to the booking page needs to know which slots are open for the next few days — a query that joins appointments against staff schedules and is genuinely expensive to run on every page load, especially if ten people load the booking page in the same minute after a promotional LINE broadcast goes out:
async function getAvailableSlots(staffId: string, date: string) {
const cacheKey = `slots:${staffId}:${date}`;
const cached = await redis.get(cacheKey);
if (cached) return cached as TimeSlot[];
const slots = await computeAvailableSlots(staffId, date); // the expensive query
await redis.set(cacheKey, slots, { ex: 60 }); // cache for 60 seconds
return slots;
}
The short 60-second TTL here is deliberate — slot availability changes the moment someone books, and serving stale availability risks showing an already-taken slot as open. That's a worse experience than the query cost you're trying to save. Contrast that with something like a restaurant's menu data, which barely changes and can be cached far longer.
How Do You Choose TTL Values?
TTL (time-to-live) should track how expensive staleness actually is, not just how expensive the query is:
- Slot/appointment availability: 30-60 seconds — staleness directly causes double-bookings
- Restaurant menu or clinic treatment list: 1-4 hours — changes infrequently, brief staleness is harmless
- Staff/admin session data: hours to days, depending on your security requirements
- AI-generated content (a Claude-generated FAQ answer, a summarized review) : hours to days — the input rarely changes and regenerating costs real API spend
- Rate limit counters: matched to your limit window, typically 1 minute to 1 hour
When unsure, start short and lengthen the TTL once you've watched real cache hit rates and staleness tolerance in production — guessing long TTLs upfront on anything booking-related is how double-bookings happen.
How Do You Invalidate Cache on a Booking Change?
When a booking is confirmed, the cached slot list for that staff member and date is now wrong. Invalidate it immediately rather than waiting out the TTL:
async function confirmBooking(staffId: string, date: string, slotId: string) {
await db.insert(appointments).values({ staffId, date, slotId, status: "confirmed" });
await redis.del(`slots:${staffId}:${date}`); // force a fresh query on next request
}
For broader invalidation — updating a clinic's pricing should invalidate every cached treatment list that includes it — use a tag-style pattern: store the set of cache keys under a tag key (a Redis set), then delete every key in that set when the tag is invalidated.
How Do You Use Redis for Rate Limiting?
Upstash's @upstash/ratelimit package implements rate limiting on top of Redis with a couple of lines:
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute per identifier
});
const { success } = await ratelimit.limit(identifier);
if (!success) return Response.json({ error: "Too many requests" }, { status: 429 });
This matters for two endpoints we see abused more than others on Bangkok client sites: a public booking API (bots probing for open slots or attempting to spam bookings) and a LINE webhook endpoint, which needs its own protection since a misbehaving LIFF client or an unexpected traffic pattern from LINE's platform can otherwise hammer your function. It's also worth putting in front of any endpoint that calls an LLM API — a single unrate-limited endpoint calling Claude or OpenAI on every request is a direct line from "someone found your API route" to an unexpectedly large bill.
How Do You Prevent Duplicate PromptPay Webhook Processing?
Payment webhooks — from Omise, 2C2P, or a bank gateway confirming a PromptPay charge — occasionally arrive more than once. The gateway retries if it doesn't get a fast 200 response, and network hiccups on either end can cause genuine duplicates. If your webhook handler naively marks a booking as paid and sends a confirmation every time it fires, a patient can end up with three duplicate "your appointment is confirmed" LINE messages for one payment.
Redis makes this a one-line fix using SET with NX (only set if the key doesn't already exist) as an idempotency lock:
async function handlePromptPayWebhook(chargeId: string) {
const lockKey = `webhook:processed:${chargeId}`;
const isNew = await redis.set(lockKey, "1", { nx: true, ex: 86400 });
if (!isNew) return; // already processed this charge, safely ignore
await confirmBookingPayment(chargeId);
}
The 24-hour expiry is generous enough to cover any realistic retry window from the payment gateway while not accumulating stale keys forever. This same pattern applies to any webhook-driven flow — LINE message events, a courier delivery-status callback — where "processed exactly once" actually matters.
Ready to Build Something Fast?
Get a free quote. We reply within 24 hours.
Ready to build something fast and scalable?
Get a free project quote. We reply within 24 hours.
Get a Free Quote →