Back to path
AdvancedInvoiceDesk · Project 9 of 12 ~6h· 5 milestones

Cache it and rate limit it

Continues from the last build: Lists are indexed, paginated by cursor, and searchable; you have baseline latency numbers.

Lists are fast now: cursor pagination, the right indexes, no N+1 queries, and you've got baseline latency numbers to prove it.

Redis cache-asideCache invalidationHTTP cache headersSliding window rate limitingSingleflight lockingLoad testingNode vs edge runtimeRedis sorted sets

What you'll build

You can now say: the dashboard reads from Redis in under 20ms, every write busts the right key the moment it commits, the public pay page serves cache-forever receipts and never-cache pending statuses, login and the pay page are rate limited by a shared Redis sliding window that survives multiple instances, and a 50-request stampede on one cold cache key triggers exactly one database query instead of fifty.

See how we teach, before you sign up

You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:

lib/cache.tstypescript
import { redis } from "@/lib/redis";
import { computeDashboardAggregates } from "@/lib/dashboard-queries";

const DASHBOARD_TTL_SECONDS = 60;

function dashboardCacheKey(orgId: string) {
  return `org:${orgId}:dashboard`;
}

export async function getDashboardAggregates(orgId: string) {
  const key = dashboardCacheKey(orgId);
  // TODO milestone 1: try redis.get(key), parse and return on a hit
  // TODO milestone 5: on a miss, acquire a SET NX PX lock before computing
  // so 50 concurrent misses don't all hit Postgres at once
  const fresh = await computeDashboardAggregates(orgId);
  await redis.set(key, JSON.stringify(fresh), "EX", DASHBOARD_TTL_SECONDS);
  return fresh;
}

export async function bustOrgCache(orgId: string) {
  // TODO milestone 2: DEL the dashboard key, called after every
  // invoice/payment mutation commits, not before
}

Reading this file

  • const DASHBOARD_TTL_SECONDS = 60;The safety-net expiry; explicit invalidation is what should actually keep this correct.
  • // TODO milestone 1: try redis.get(key), parse and return on a hitFirst thing to build: the read side of cache-aside.
  • // TODO milestone 5: on a miss, acquire a SET NX PX lock before computingAdded last, once the basic cache works, to protect against concurrent misses.
  • export async function bustOrgCache(orgId: string) {Called from every write path in milestone 2, not yet implemented here.

Starter file for this rung.

That's 1 of 8 explained code blocks in this single project.

The build, milestone by milestone

  1. 1

    Cache the dashboard aggregates in Redis

    5 guided steps

    Cache-aside separates read cost from write cost without touching Postgres's role as source of truth, and a short TTL bounds how stale the numbers can get even if you forget to invalidate somewhere.

  2. 2

    Invalidate the cache on every write, correctly

    5 guided steps

    TTL is a safety net for bugs, crashes, or a mutation you forgot to wire up, it is not the invalidation strategy itself; correctness comes from explicit busting on write, TTL just bounds the blast radius if that ever fails.

  3. 3

    Set correct HTTP cache headers on the public pay page

    5 guided steps

    A paid receipt will never change again, letting shared caches serve it for a day takes real load off the origin. A pending invoice can change the instant the client pays, serving it stale for even a minute risks showing 'unpaid' to someone who already paid.

  4. 4

    Rate limit login and the public pay page

    5 guided steps

    An in-memory limiter (a Map at module scope) works in dev with one process, but production runs multiple instances behind a load balancer; each instance would have its own counter, silently multiplying the real limit, and a restart wipes it entirely. Only a shared store like Redis enforces the real limit across every instance.

  5. 5

    Survive a cache stampede on one org key

    5 guided steps

    Without protection, a single expired key under concurrent load turns into N duplicate expensive queries hitting the database at once, which is exactly the kind of spike that can tip a database into connection exhaustion during a traffic surge.

What's inside when you start

3 starter files, ready to clone
5 guided milestones
5 full reference solutions
8 code blocks explained line-by-line
5 "is it working?" checks
6 interview questions it prepares you for

You'll walk away with

lib/cache.ts with cache-aside reads, a 60s TTL, and a singleflight lock protecting the dashboard aggregates
bustOrgCache wired into every invoice/payment mutation and the Stripe payment_intent.succeeded webhook
app/pay/[token]/route.ts serving public, s-maxage=86400 for paid/void invoices and private, no-store for pending/overdue
lib/rate-limit.ts enforcing sliding-window limits on login and the pay page, returning 429 with Retry-After
tests/load/dashboard-stampede.ts proving exactly one compute call survives 50 concurrent misses on one org key
Verified numbers: cache hit under 20ms, rate limiter blocks past its configured threshold, stampede test shows 1 compute call

This is portfolio-grade. Build it free.

Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.

Start building