URL Shortener Tutorial · Module 04 of 11

Caching & Performance

By the end of this tutorial you'll have Redis caching the redirect path with jittered TTLs and negative caching, a graceful fallback if Redis dies, and a k6 load test that proves p95 latency dropped.

~3–4 hrsRedisCache-asidek6
← Back to Module 04 overview
Before You Start

Prerequisites

  • Modules 01–03 complete with passing tests.
  • Docker running.
  • k6 installed (brew install k6 / scoop / chocolatey / Linux package).
What You'll Have at the End

Definition of Done

  • Redis running in Docker alongside Postgres.
  • Cache-aside reads on GET /:code; cache invalidation on delete.
  • Jittered TTLs and negative caching for 404s.
  • Service stays up if Redis is killed (logs the error, falls back to DB).
  • A perf/redirect.js k6 script and a docs/perf/before-after.md writeup with the p95 numbers.
The Steps

Build It

STEP 1

Add Redis to docker-compose

services:
  # …existing db service…
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      retries: 10
docker compose up -d redis
✓ Verify: docker compose exec redis redis-cli pingPONG.
STEP 2

Install the Redis client

npm install ioredis

Add to .env and .env.example:

REDIS_URL=redis://localhost:6379
STEP 3

Create a cache module

Create src/cache.ts:

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
  lazyConnect: true,
  maxRetriesPerRequest: 1,
  enableOfflineQueue: false
});
redis.on('error', (e) => console.warn('redis error:', e.message));
redis.connect().catch(() => { /* tolerate startup failure */ });

const HOUR = 60 * 60;
const jitter = (s: number) => Math.floor(s * (0.9 + Math.random() * 0.2));

export async function getLink(code: string): Promise<string | null | undefined> {
  try {
    const v = await redis.get(`link:${code}`);
    if (v === null) return undefined;     // cache miss
    if (v === 'MISS') return null;        // negatively cached: known-404
    return v;                              // cached hit
  } catch { return undefined; }            // Redis down — degrade to DB
}

export async function setLinkHit(code: string, target: string) {
  try { await redis.set(`link:${code}`, target, 'EX', jitter(HOUR)); } catch {}
}

export async function setLinkMiss(code: string) {
  try { await redis.set(`link:${code}`, 'MISS', 'EX', 60); } catch {}
}

export async function invalidateLink(code: string) {
  try { await redis.del(`link:${code}`); } catch {}
}

Notice: every Redis call is wrapped — Redis being down logs but never throws to the request handler.

STEP 4

Wire the cache into the redirect path

In src/app.ts update the GET and DELETE handlers:

import { getLink, setLinkHit, setLinkMiss, invalidateLink } from './cache.js';

app.get('/:code', async (req, res) => {
  const { code } = req.params;

  const cached = await getLink(code);
  if (cached === null) return res.sendStatus(404);
  if (typeof cached === 'string') return res.redirect(302, cached);

  const { rows } = await pool.query(
    'SELECT target_url FROM links WHERE code = $1', [code]
  );
  if (rows.length === 0) {
    await setLinkMiss(code);
    return res.sendStatus(404);
  }
  await setLinkHit(code, rows[0].target_url);
  res.redirect(302, rows[0].target_url);
});

app.delete('/:code', async (req, res) => {
  const result = await pool.query('DELETE FROM links WHERE code = $1', [req.params.code]);
  await invalidateLink(req.params.code);
  res.sendStatus(result.rowCount === 0 ? 404 : 204);
});
STEP 5

Smoke test the cache behavior

npm run dev

# Create
curl -s -X POST http://localhost:3000/shorten \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com"}'   # remember the code

# First request — DB miss, cache populated
curl -s -o /dev/null -w '%{time_total}\n' http://localhost:3000/<code>

# Second request — should be ~half the latency
curl -s -o /dev/null -w '%{time_total}\n' http://localhost:3000/<code>

# Confirm in Redis
docker compose exec redis redis-cli get link:<code>
✓ Verify: Redis shows the target URL; second curl is faster than the first.
STEP 6

Test negative caching

curl -i http://localhost:3000/this-does-not-exist     # 404
docker compose exec redis redis-cli get link:this-does-not-exist
# → "MISS" (cached for 60s)

For the next 60 seconds, repeated 404s for this code don't hit Postgres. Watch the Postgres log to confirm.

STEP 7

Add an integration test for the cache path

Append to tests/api.test.ts:

it('serves a redirect from cache after a warm read', async () => {
  const r = await request(app()).post('/shorten').send({ url: 'https://example.com/cache' });
  const code = r.body.code;
  await request(app()).get(`/${code}`);                  // warms cache
  const cold = await request(app()).get(`/${code}`).redirects(0);
  expect(cold.status).toBe(302);
  expect(cold.headers.location).toBe('https://example.com/cache');
});

For tests, point REDIS_URL at a Testcontainers Redis (mirror the Postgres pattern), or skip Redis in tests by short-circuiting cache.ts when NODE_ENV === 'test'.

STEP 8

Write the k6 load test

Create perf/redirect.js:

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  stages: [
    { duration: '15s', target: 50  },
    { duration: '60s', target: 200 },
    { duration: '15s', target: 0   }
  ],
  thresholds: {
    http_req_failed:   ['rate<0.01'],
    http_req_duration: ['p(95)<50']
  }
};

const code = __ENV.CODE;
const base = __ENV.BASE || 'http://localhost:3000';

export default function () {
  const res = http.get(`${base}/${code}`, { redirects: 0 });
  check(res, { 'is 302': (r) => r.status === 302 });
}
STEP 9

Measure before vs after

Disable the cache temporarily by short-circuiting getLink to return undefined, run the load test, record the numbers. Then re-enable and run again.

# Create a code first
CODE=$(curl -s -X POST http://localhost:3000/shorten \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com"}' | jq -r .code)

# Run
CODE=$CODE k6 run perf/redirect.js

Capture both runs in docs/perf/before-after.md:

# Redirect Performance — Before / After Caching

| metric          | no cache | with cache |
|-----------------|----------|------------|
| p50 (ms)        |    XX    |     XX     |
| p95 (ms)        |    XX    |     XX     |
| RPS sustained   |    XX    |     XX     |
| Postgres CPU    |    XX %  |     XX %   |
✓ Verify: p95 with cache is comfortably below 50ms; without cache, you saturate Postgres far sooner.
STEP 10

Verify graceful degradation

docker compose stop redis
curl -i http://localhost:3000/<code>       # still works (slower)
docker compose start redis

The app should log Redis errors but keep serving traffic.

STEP 11

Commit

git checkout -b module-04
git add .
git commit -m "module 04: redis cache-aside, jittered TTLs, k6 load test"
git push -u origin module-04
Common Gotchas

If Something Goes Wrong

  • Redis errors in tests — easiest fix is to short-circuit the cache module when NODE_ENV === 'test' or use Testcontainers to spin up a Redis.
  • k6 thresholds fail — check whether your laptop is the bottleneck (low RPS, high CPU); try smaller targets or run k6 from a separate machine.
  • Stale data after a delete — make sure you call invalidateLink in the DELETE handler.
  • "Redis client is closed" — the lazyConnect + connect() pattern only connects once; if you crash and restart, give it a moment.
What's Next

Move On

The redirect path is fast and resilient. Now we make sure not just anyone can create or delete links — auth time.