The last chapter built the redirect with a deliberate 302 so every click flows back through our handler. Now we use that hook: this chapter adds click tracking — incrementing a per-URL counter on each successful GET /:code redirect. The interesting part is how we increment, because the naive version silently loses counts under concurrent traffic.

incrementClicks(shortCode) method on the chapter-6 UrlStore seam, implemented in both backends.findByCode, then awaiting incrementClicks before sending the 302.Before the code, here are the three ideas the whole chapter turns on.
Atomic increment. One SQL statement — UPDATE urls SET clicks = clicks + 1 — where the database does the read and the add and the write as a single indivisible step. Nothing ever leaves the database, so there's no window for a concurrent hit to slip into.
Read-modify-write. The naive alternative, done in three steps in application code: read the current , add in JS, write the new value back. It's correct one request at a time, but racy under concurrency.
clicks1Lost update. What read-modify-write does wrong under load: two concurrent clicks both read 5, both compute 6, and both write 6 — so two hits move the counter by one and one click vanishes.
The whole chapter turns on this one design choice, so it's worth settling before we write a test. Two redirects for the same code arrive at once, both starting from clicks = 5. With read-modify-write, both SELECT and see 5, both add 1 in their own copy of the value, and both write 6 back — the second write silently clobbers the first, and the counter lands on 6 instead of 7. Two hits happened; only one was counted. That's the lost update, and it's invisible: no error, no crash, just a number that's quietly too low.
The fix is to never pull the value into application memory at all:
sqlUPDATE urls SET clicks = clicks + 1 WHERE short_code = $1;
clicks = clicks + 1 is computed by Postgres under the row lock the UPDATE already takes, so two concurrent statements serialize: one runs, then the other reads the already-updated value and adds to it. The counter lands on 7 no matter how many hits arrive at once. That's the entire reason to do the arithmetic in SQL rather than in JS — and it's exactly what the diagram above contrasts.
One thing we don't need: a migration. The clicks column already exists — we added it back in chapter 11 (clicks Int @default(0)), so a freshly shortened URL already starts at 0. This chapter touches nothing in prisma/; all we add is the code that moves an existing column.
We drive this with an integration test, because the behaviour we care about is a real number landing in a real urls row. The test persists a URL, hits GET /:code five times, then reads clicks straight from the database and asserts it equals five.
Check out the start branch and bring up the database:
bashgit checkout 14-tracking-clicks-startnpm installdocker compose up -d --wait
The new suite wires buildApp with PrismaUrlRepository so it exercises the full path through Postgres. The driver case persists a URL, hits GET /:code five times, then reads clicks straight from the database:
typescript// __tests__/integration/click-tracking.test.tsit("records N clicks after N successful redirects", async () => {const shortCode = await shortenAndGetCode("https://dalabs.academy");const N = 5;for (let i = 0; i < N; i++) {const response = await app.inject({ method: "GET", url: `/${shortCode}` });expect(response.statusCode).toBe(302);}const row = await prisma.url.findUnique({ where: { shortCode } });expect(row?.clicks).toBe(N);});
That's the real driver. Two sibling cases pass on the start branch already — "starts at zero" (the column defaults to 0) and "no increment on a 404" (an unknown code is never stored). The honest red is this one: the handler doesn't count hits yet, so after five GETs the row's clicks stays at its default. We assert against the database directly with prisma.url.findUnique, because that row is the source of truth the stats endpoint (chapter 16) will read later.
Run the integration suite:
bashnpm run test:integration

One honest behavioural failure — the redirect returns its 302 five times, but nothing increments the counter, so the row reads 0 instead of 5. With the container up, this is a real behaviour gap, not a connectivity or compile error.
incrementClicks to the Seam (Green)Switch to the finish branch:
bashgit checkout 14-tracking-clicks-finishnpm install
The new behaviour goes behind the same UrlStore interface the redirect already depends on. We add one method, incrementClicks, and implement it in both backends. The handler then just calls it — it never learns which store it's talking to.
typescript// src/services/url.service.tsexport interface UrlStore {save(shortCode: string, url: string): Promise<void>;findByCode(shortCode: string): Promise<string | undefined>;incrementClicks(shortCode: string): Promise<void>;}export class UrlService implements UrlStore {private readonly urls = new Map<string, string>();private readonly clicks = new Map<string, number>();async save(shortCode: string, url: string): Promise<void> {this.urls.set(shortCode, url);this.clicks.set(shortCode, 0);}async findByCode(shortCode: string): Promise<string | undefined> {return this.urls.get(shortCode);}async incrementClicks(shortCode: string): Promise<void> {if (!this.urls.has(shortCode)) return;this.clicks.set(shortCode, (this.clicks.get(shortCode) ?? 0) + 1);}async getClicks(shortCode: string): Promise<number | undefined> {return this.clicks.get(shortCode);}}
The in-memory store keeps a second Map for the counts. save seeds a new code at 0 so it mirrors the database default. incrementClicks is a no-op for a code that was never saved — that matches "no row to update" in the database. getClicks is test-only scaffolding — a string a key returns to so the Docker-free unit tests can assert the counter without a database. It is deliberately not on the UrlStore interface, because the route never needs to read a count.
The Prisma store does the increment the way that matters — atomically.
typescript// src/services/prisma-url.repository.tsasync incrementClicks(shortCode: string): Promise<void> {await this.prisma.url.update({where: { shortCode },data: { clicks: { increment: 1 } },});}
{ clicks: { increment: 1 } } is Prisma's atomic-increment shorthand. It compiles to UPDATE urls SET clicks = clicks + 1 WHERE short_code = $1 — one statement, the database doing the arithmetic. This is the read-modify-write fix from earlier, now in real code: no SELECT, no value in JS, no lost-update window.
With the seam in place, the redirect handler counts the hit after a successful lookup and before the 302. The 404 branch is untouched — an unknown code is never counted.
typescript// src/routes/redirect.tsconst originalUrl = await urlStore.findByCode(code);if (originalUrl === undefined) {reply.code(404);return {error: "Not Found",message: `No URL found for code "${code}"`,};}await urlStore.incrementClicks(code);return reply.redirect(originalUrl, 302);
The handler stays thin: it has no direct database access and talks only to the UrlStore seam, so the exact same code runs against the in-memory store (unit tests) and Prisma (integration and production). That's the chapter-6 seam paying off once more — a new storage capability slots in behind the interface without the route changing shape.
There is one real decision left: do we await the increment, or fire it off and return the redirect immediately? We chose to await — and the trade-off is worth understanding because the other choice is defensible in a different context.
| Awaited (chosen) | Fire-and-forget | |
|---|---|---|
| Latency | Adds one round trip before the 302 | Returns the redirect immediately |
| Correctness | Increment is guaranteed before the response returns | Increment may not have run yet when the response leaves |
| Failure visibility | A failed UPDATE surfaces — the handler awaits a rejected promise | Becomes a silent unhandled rejection; the count is lost on a crash |
| Complexity | None — just await | Needs a deliberate .catch() so a rejection can't take the process down |
Why awaited here. At this stage correctness and simplicity beat shaving a few milliseconds off a redirect. Awaiting keeps the count exact and lets a failed increment surface instead of vanishing. Fire-and-forget — return the 302 first, run the increment in the background — is the right call when redirect latency dominates and an occasional lost count is acceptable. But it needs an explicit .catch() so an unhandled rejection can't crash the process, and it trades a guarantee for speed. We note the trade-off and pick the guarantee.
The atomic increment we just wrote already makes concurrent click counting correct. Ten redirects for the same code arriving at once all run SET clicks = clicks + 1, the statements serialize on the row lock, and the counter moves by exactly ten. No application-level lock, no retry loop — the database handles it. We flag concurrency here so it's fresh, but this particular race is solved.
A different operation is still racy, and it's the one chapter 19 tackles: short-code generation. Two concurrent POST /shorten requests can independently generate the same code, both check "is it free?", both see "yes", and then one INSERT wins while the other hits the urls_short_code_key unique constraint. The @unique index makes a duplicate impossible to persist, but turning that constraint violation into graceful behaviour — retry with a fresh code, or serialize generation with a PostgreSQL advisory lock — is the chapter-19 topic. So: atomic increment solves concurrency for counting; advisory locks solve it for unique-code generation.
The finish branch also adds three Docker-free unit cases that exercise the in-memory store's counter directly (start at zero, N increments yield N, ignore an unknown code), so the increment logic is proven fast without a container. Run everything:
bashnpm testnpm run test:integration

Green on both. The unit suite proves the increment logic in milliseconds; the integration test proves five real redirects land clicks = 5 in an actual Postgres row.
When you're done, stop the container:
bashdocker compose down -v
The database is accumulating real usage data, but there's no way to see what's stored. Next we list all URLs with a paginated GET /urls endpoint, with schema-validated paging params.