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.
← Back to Module 04 overviewbrew install k6 / scoop / chocolatey / Linux package).GET /:code; cache invalidation on delete.perf/redirect.js k6 script and a docs/perf/before-after.md writeup with the p95 numbers.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
docker compose exec redis redis-cli ping → PONG.npm install ioredis
Add to .env and .env.example:
REDIS_URL=redis://localhost:6379
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.
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);
});
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>
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.
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'.
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 });
}
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 % |
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.
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
NODE_ENV === 'test' or use Testcontainers to spin up a Redis.invalidateLink in the DELETE handler.lazyConnect + connect() pattern only connects once; if you crash and restart, give it a moment.The redirect path is fast and resilient. Now we make sure not just anyone can create or delete links — auth time.