Week 11: Caching & Performance

Week 9 pushed you toward non-blocking, streaming I/O, and Week 10 added a persistent connection your server has to serve fast. Neither one helps if the underlying work — a heavy database query hit on every single request — is just slow. This week is about finding that kind of slowness and removing it: the cache-aside pattern with Redis, tuning how many database connections your Prisma client actually opens, and profiling a real endpoint before optimizing it, since the next two weeks (background jobs, then observability) both assume you can already tell a healthy endpoint from an unhealthy one.

Module 8 of 22 Week 11 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Implement the cache-aside pattern with Redis, including correct invalidation on writes
  • Tune Prisma's database connection pool instead of leaving it at an untested default
  • Profile a slow endpoint, find its real bottleneck, and prove a fix with before/after numbers

1. Why Cache & the Cache-Aside Pattern

A database query that takes 40ms is fine for one request and disastrous at 2,000 requests per second against the same row — the database, not your Express process, becomes the bottleneck. Caching sidesteps this by keeping a copy of expensive-to-compute data somewhere much faster to read than a database round trip, most commonly Redis, an in-memory data store that answers a key lookup in well under a millisecond.

The most common caching strategy for a REST API is cache-aside (also called lazy loading): your application code, not the database, owns the caching logic. On a read, check the cache first; on a hit, return the cached value and skip the database entirely; on a miss, query the database, write the result into the cache with an expiration, and return it. On a write, invalidate (delete) the cache key rather than trying to keep it in sync — the next read repopulates it.

cache-aside, in words
GET /products/42
  -> cache.get("product:42")
     -> HIT:  return cached value                (fast path, no DB)
     -> MISS: value = db.query(...)
              cache.set("product:42", value, ttl)
              return value

PUT /products/42
  -> db.update(...)
  -> cache.delete("product:42")                  (never write-through the cache here)

The trade-off is a bounded staleness window: between a write and the next read that repopulates the cache, a client could theoretically read data that was just deleted, never written — but never stale, since the delete happens before the response is returned. A short TTL (time to live) also bounds how long any cache entry can drift from the database even if an invalidation is ever missed, which makes cache-aside forgiving of bugs in a way that keeping the cache perpetually in sync is not.

Cache what's expensive and reused, not everything

Caching adds a second system that can be wrong. Reach for it when a query is measurably slow, its result is read far more often than it changes (a product catalog page, a user's profile), and a few seconds to a few minutes of staleness is acceptable — not as a reflexive first move on every route.

2. Caching with Redis in Express

ioredis is the most widely used Redis client for Node — it has solid TypeScript types and is also what BullMQ (Week 12) uses under the hood, so the connection you build this week is one you'll reuse next week. As with Prisma, create a single shared client rather than a new connection per request:

terminal
npm install ioredis
npm install -D @types/node
src/lib/redis.ts
import { Redis } from "ioredis";

// One shared connection for the whole process, exactly like the
// PrismaClient singleton pattern from Week 4 -- creating a new
// Redis instance per request exhausts connections under load.
export const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
  maxRetriesPerRequest: 3,
});

redis.on("error", (err) => {
  console.error("Redis connection error:", err);
});

With the client in place, a small helper wraps the cache-aside logic once so every route reuses it instead of re-implementing the get/miss/set dance by hand:

src/lib/cache.ts
import { redis } from "./redis.js";

/**
 * Cache-aside helper: returns the cached value if present, otherwise
 * calls `loadFresh`, caches the result for `ttlSeconds`, and returns it.
 */
export async function getOrSetCache<T>(
  key: string,
  ttlSeconds: number,
  loadFresh: () => Promise<T>
): Promise<T> {
  const cached = await redis.get(key);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }

  const fresh = await loadFresh();
  // "EX" sets the TTL in seconds atomically with the write.
  await redis.set(key, JSON.stringify(fresh), "EX", ttlSeconds);
  return fresh;
}

export async function invalidateCache(key: string): Promise<void> {
  await redis.del(key);
}
src/routes/products.ts
import { Router } from "express";
import { prisma } from "../lib/prisma.js";
import { getOrSetCache, invalidateCache } from "../lib/cache.js";

export const productsRouter = Router();

productsRouter.get("/:id", async (req, res, next) => {
  try {
    const id = req.params.id;
    const product = await getOrSetCache(`product:${id}`, 60, () =>
      prisma.product.findUniqueOrThrow({ where: { id } })
    );
    res.json(product);
  } catch (err) {
    next(err);
  }
});

productsRouter.put("/:id", async (req, res, next) => {
  try {
    const id = req.params.id;
    const updated = await prisma.product.update({
      where: { id },
      data: req.body,
    });
    await invalidateCache(`product:${id}`); // never let a write leave a stale cache entry
    res.json(updated);
  } catch (err) {
    next(err);
  }
});
Namespace and version your keys

Prefixing keys with an entity name (product:42) and, for shapes that change often, a schema version (product:v2:42) means a future field change can't silently deserialize old cached JSON into the wrong shape — you just bump the prefix and old entries age out unused.

3. Connection Pooling for Prisma

Every database connection costs memory and setup time on the Postgres side, and Postgres caps the total it will accept (max_connections, often 100 by default). Prisma's client opens and manages its own pool of connections rather than opening one per query, and you control the pool size directly in the connection string:

.env
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb?connection_limit=10&pool_timeout=10"

connection_limit caps how many connections this single Prisma client will open; pool_timeout is how long a query waits for a free connection before throwing, in seconds. The right number isn't "as high as possible" — Prisma's own guidance is roughly num_physical_cpus * 2 + 1 per instance of your app as a starting point, then measure. If you run multiple instances of your API (which you will, starting in Week 14), each one opens its own pool of that size, so five instances at connection_limit=10 means up to 50 connections hitting Postgres — easy to exceed max_connections without realizing it.

src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";

// A single shared client for the whole process -- this is the pool.
// Never call `new PrismaClient()` inside a request handler; each
// instance opens its own connection pool independently.
export const prisma = new PrismaClient({
  log: process.env.NODE_ENV === "development" ? ["query", "warn", "error"] : ["error"],
});

For serverless or high-instance-count deployments where dozens of short-lived processes would otherwise each open their own pool, the standard fix is a connection pooler like PgBouncer sitting between your app and Postgres, multiplexing many logical client connections onto a much smaller number of real database connections. This course's deployments (Week 14 onward) run a small, fixed number of long-lived Node processes, so a direct pool with a sane connection_limit is enough — but knowing PgBouncer exists is what saves you the day you scale past that.

Watch for pool exhaustion, not just slow queries

A symptom that looks like "the database is slow" is often "every connection in the pool is already busy and new queries are queuing behind pool_timeout." If p99 latency spikes under load but individual query times in your database logs look normal, check pool utilization before assuming the query itself needs work.

4. Profiling & Fixing a Slow Endpoint

Never optimize a hunch — measure first. A simple timing middleware gives you a baseline for every route without touching route logic:

src/middleware/timing.ts
import type { Request, Response, NextFunction } from "express";

export function timing(req: Request, res: Response, next: NextFunction): void {
  const start = process.hrtime.bigint();

  res.on("finish", () => {
    const ms = Number(process.hrtime.bigint() - start) / 1_000_000;
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms.toFixed(1)}ms`);
  });

  next();
}

For load rather than single-request timing, a tool like autocannon hammers an endpoint with concurrent requests and reports latency percentiles:

terminal
npx autocannon -c 50 -d 10 http://localhost:3000/api/reports/summary

# Before any fix, on an endpoint that runs an unindexed aggregate query:
# Latency:   avg 412ms   p50 380ms   p99 610ms
# Req/sec:   avg 118

Once a route is confirmed slow, find out why before reaching for Redis. Postgres's EXPLAIN ANALYZE shows the actual query plan and where time is spent:

psql
EXPLAIN ANALYZE
SELECT * FROM "Order" WHERE "customerId" = 'cus_123' ORDER BY "createdAt" DESC;

-- Before: Seq Scan on "Order"  (cost=0.00..8420.00 rows=50000)
--         Planning Time: 0.4 ms   Execution Time: 344.8 ms

A sequential scan across the whole table for a query that filters on customerId is the classic sign of a missing index. Adding one is usually a one-line Prisma schema change, and the improvement is dramatic because it turns an O(n) table scan into an O(log n) index lookup:

schema.prisma
model Order {
  id         String   @id @default(cuid())
  customerId String
  createdAt  DateTime @default(now())
  // ...

  @@index([customerId, createdAt])
}
terminal
npx prisma migrate dev --name add_order_customer_index

# After adding the index and re-running EXPLAIN ANALYZE:
# Index Scan using Order_customerId_createdAt_idx
#         Execution Time: 3.1 ms

# Re-running autocannon against the same endpoint:
# Latency:   avg 24ms   p50 19ms   p99 61ms
# Req/sec:   avg 1,890

Only after the query itself is efficient does caching earn its keep — an index fix made every request faster and cheaper for the database; a cache on top of the same indexed query removes the database round trip entirely for repeat reads. Reaching for Redis first on an unindexed query hides the real problem behind a TTL instead of fixing it.

Fix the query, then cache the result

The two techniques in this lesson compose: index the slow query first so every request (including cache misses) is fast, then cache-aside it so most requests never hit the database at all. Doing it in the other order leaves an expensive query hiding behind a TTL, ready to hurt you the moment the cache is cold or invalidated.

5. Hands-on Exercise

Hands-on

Cache a real endpoint and prove the fix with numbers

Take a route backed by Prisma, measure it honestly, fix its actual bottleneck, then add cache-aside on top — and record the before/after difference at every step.

Requirements:

  1. Add the timing middleware from Section 4 to your app and pick (or add) a route that queries a table with at least a few thousand seeded rows.
  2. Run autocannon against it and record the baseline latency percentiles and requests/sec.
  3. Run EXPLAIN ANALYZE on the underlying query, identify whether it's doing a sequential scan, and add an appropriate Prisma @@index if so. Re-run autocannon and record the new numbers.
  4. Add Redis and wrap the route's read with getOrSetCache from Section 2, with a sensible TTL. Re-run autocannon a third time.
  5. Add the matching invalidation call to whichever route mutates that data, and write a quick manual test proving a read after a write never returns stale data.
  6. Write a short comment block at the top of the route file listing all three latency numbers (baseline, indexed, cached) side by side.
Hint

Seed enough rows that the "before" numbers are actually bad — a table with 50 rows will look fast no matter what, and you won't see the sequential scan cost or the caching win clearly. A few thousand rows is usually enough to make a missing index obvious in EXPLAIN ANALYZE.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

In cache-aside, why does a write delete the cache key instead of updating it with the new value directly?

Writing the new value into the cache (write-through) duplicates the update logic in two places and risks the cache and database disagreeing if the write to one succeeds and the other fails, or if two concurrent writes race and leave the cache holding a value that isn't the latest. Deleting the key is simpler and self-correcting: the next read is guaranteed to reload from the database, which is always the source of truth, so there's no window where the cache actively lies about the current value.

Q2

Why can raising connection_limit on every instance of your API make things worse under load rather than better?

Each running instance of your app opens its own independent pool of that size, so the total connections hitting Postgres is connection_limit × number of instances -- raising the per-instance limit multiplies across every instance you run. Once that total approaches Postgres's max_connections cap, new connection attempts start failing or queuing, which shows up as intermittent errors and latency spikes that look like a database performance problem but are actually a connection-count problem.

Q3

Why fix the missing index before adding a Redis cache in front of the same slow query, rather than caching first?

Caching only speeds up requests that hit an already-warm cache key -- every cache miss, cold start, and TTL expiry still runs the full unindexed query, so the underlying slowness never actually goes away, it's just hidden most of the time. Fixing the index makes every request fast, including misses, and stacking a cache on top of that removes the database round trip for repeat reads without leaving a slow path lurking underneath.

Q4

Why is a short TTL still worth setting on a cache entry even if you're confident your invalidation logic is correct?

Invalidation logic is code, and code has bugs -- a missed invalidateCache call on some new write path, a crash between the database write and the cache delete, or a direct database edit outside the app will all leave a stale entry with no TTL sitting there indefinitely. A TTL puts a hard ceiling on how stale any entry can ever get, so a missed invalidation degrades into "wrong for at most N seconds" instead of "wrong forever until someone notices."