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, which means incrementing a per-URL counter on each successful GET /:code redirect. The interesting part is how we increment. A naive approach silently loses counts under concurrent traffic.

incrementClicks(shortCode) method to the UrlStore interface, implemented in both backends.findByCode, then awaits incrementClicks before sending the 302.Before looking at the code, here are three concepts that drive this chapter.
Atomic increment: One SQL statement (UPDATE urls SET clicks = clicks + 1) where the database reads, adds, and writes as a single indivisible step. The data never leaves the database, leaving no window for a concurrent hit to slip through.
Read-modify-write: The naive alternative. This happens in three steps in application code: read the current clicks, add in JavaScript, and write the new value back. It works fine for one request at a time but causes race conditions under heavy traffic.
1Lost update: This is what goes wrong with read-modify-write under load. Two concurrent clicks both read 5, both compute 6, and both write 6. Two hits occurred, but the counter only moved by one. A click vanishes.
Doing the math in SQL is a specific design choice worth settling before we write a test. Imagine two redirects for the same code arrive at once, both starting from clicks = 5. With read-modify-write, both requests SELECT and see 5. Both add 1 in their own copy of the value, and both write 6 back to the database. The second write silently overwrites the first, and the counter lands on 6 instead of 7. Two hits happened, but only one was counted. This is a lost update. It is completely invisible. There is no error and no crash, just a number that is 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;
Postgres computes clicks = clicks + 1 under the row lock the UPDATE already takes. Two concurrent statements will serialize. One runs, then the other reads the newly updated value and adds to it. The counter lands on 7 no matter how many hits arrive at once. This is the reason we do the arithmetic in SQL rather than in JavaScript.
One thing we do not need is a database migration. The clicks column already exists. We added it back in chapter 11 as clicks Int @default(0), so a freshly shortened URL already starts at 0. This chapter touches nothing in the prisma/ folder. All we add is the code that updates an existing column.
We drive this with an integration test because the behavior 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);});
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 test is the one above. The handler does not count hits yet, so after five GET requests, the row's clicks value 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 will read in chapter 16.
Run the integration suite:
bashnpm run test:integration

This is an honest behavioral 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 behavior 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 behavior 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 calls it without ever learning which store it is 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. The save method seeds a new code at 0 so it mirrors the database default. incrementClicks is a no-op for a code that was never saved, which matches the "no row to update" behavior in the database.
Notice getClicks. This is test-only scaffolding. It allows the Docker-free unit tests to assert the counter without a database. It is deliberately missing from 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. This is one statement where the database does the arithmetic. This is the read-modify-write fix from earlier, now in real code. There is no SELECT, no value held in JavaScript, and no lost-update window.
With the interface updated, the redirect handler counts the hit after a successful lookup and before sending 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 interface. The exact same code runs against the in-memory store for unit tests and Prisma for integration and production. This is 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. 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 means returning the 302 first and running the increment in the background. This is the right call when redirect latency dominates and an occasional lost count is acceptable. However, it needs an explicit .catch() so an unhandled rejection cannot 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 makes concurrent click counting correct. If ten redirects for the same code arrive at once, they all run SET clicks = clicks + 1. The statements serialize on the row lock, and the counter moves by exactly ten. There is no application-level lock and no retry loop. The database handles it. We flag concurrency here so it is fresh in your mind, but this particular race condition is solved.
A different operation is still racy, and it is the one chapter 19 tackles: short-code generation. Two concurrent POST /shorten requests can independently generate the same code. Both check if it is free, and both see that it is. Then one INSERT wins while the other hits the urls_short_code_key unique constraint.
The @unique index makes a duplicate impossible to persist. Turning that constraint violation into graceful behavior, like retrying with a fresh code or serializing generation with a PostgreSQL advisory lock, is the topic of chapter 19. Atomic increment solves concurrency for counting. Advisory locks solve it for unique-code generation.
The finish branch also adds three Docker-free unit cases. These exercise the in-memory store's counter directly (start at zero, N increments yield N, ignore an unknown code). This proves the increment logic works quickly without a container. Run everything:
bashnpm testnpm run test:integration

Both suites are green. 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 is no way to see what is stored. Next, we list all URLs with a paginated GET /urls endpoint using schema-validated paging parameters.