Analytics on the hot path
The owner wants a click count, so the redirect adds one to a column. Why does a viral link make every redirect slow?
Before this: The stale redirect·Next: The vanity alias race
In 30 seconds
- The problem
- After the redirect starts counting clicks in Postgres, it gets slower with every click on a popular link, and can take unrelated links down with it.
- Why it happens
- Every click runs an UPDATE on the same row, and Postgres lets one transaction change a row at a time. Click number 50 waits for the 49 ahead of it.
- The fix
- Count in Redis with INCR on each click, and let a background worker add the total to Postgres every few seconds.
- In an interview
- The hot path does only what the user is waiting for. Count in memory, flush to the database in batches, and say the trade-off out loud: counts lag by a few seconds.
Why it matters
We cached the destination so a redirect never touches the database. The click counter puts a database write back on every single click, on the one row everyone shares.
In our run, 50 simultaneous clicks took about 68 ms at the median and 79 ms for the slowest, against a 50 ms budget for a redirect. That is 50 clicks on a laptop with an idle database. A real viral link is thousands a second on a database that is busy with other work, and each waiting click also holds a connection from the pool. When the pool runs dry, every other link waits too.
Words you need
- Hot path
- The code that runs on every request and that the user is waiting on. Here, the redirect itself. Anything extra you put there is paid on every click.
- Row lock
- While one transaction updates a row, the database makes every other update to that row wait its turn.
- Bottleneck
- The one part that everything else has to wait for.
- Batching
- Collecting many small changes and writing them once. 10,000 clicks become one UPDATE.
- Atomic
- Done as one step that nothing can interrupt. Another request sees it either not started or fully finished.
Picture it
Three people click the same link in the same instant.
Counting inside the redirect
- Click 1 locks the row
- Click 2 waits
- Click 3 waits
- Click 1 commits
- Click 2 runs
- Click 3 runs
The count is right, but every redirect now waits for its turn on one row. The more clicks, the longer the queue.
Counting off the hot path
- Click 1 INCR in Redis
- Click 2 INCR in Redis
- Click 3 INCR in Redis
- Worker adds 3 to Postgres
Each redirect finishes in under a millisecond. Postgres gets one write for the whole batch.
The fixes people try
UPDATE in the redirect
FailsUPDATE links SET clicks = clicks + 1Correct count, but every click queues on one row and holds a connection while it waits.
Fire and forget the UPDATE
Half a fixdb.query(…) // no awaitThe redirect stops waiting, but the writes still pile up on the same row and still use the pool. You moved the queue, not removed it.
Insert a row per click
Half a fixINSERT INTO clicks (code, at) …Inserts do not fight over one row, so it scales much further. It is still a database write on every click.
Spread over N counter rows
Worksclicks_shard = random(0, 9)Spreads the lock across several rows and sums them on read. Good when the count has to stay in Postgres.
Redis INCR and a periodic flush
WorksINCR clicks:<code>; flush every ~5sNo row lock, no disk, no queue on the redirect. Ten thousand clicks become one write.
Click events to a queue
Worksqueue.send({ code, at, country })The right answer when you need per-click data (country, referrer), durability, or replays.
The fix and its cost
Make the redirect do only the redirect. On each click, run INCR on a Redis key: it is atomic, in memory, and takes no row lock. A background worker reads and clears the key every few seconds with GETDEL and adds the total to Postgres in one UPDATE.
This is the same idea as the cache we already have: keep the slow, shared thing off the path the user is waiting on.
What it costs
Interviewers also listen for: durability and 301
Check yourself
1The count is correct. Why is this still a bug?Show answerHide
The cost is the problem, not the number. The read path now writes, and every click waits on one row lock and holds a connection while it does.
2Why can one hot link slow down other links?Show answerHide
Waiting clicks each hold a database connection. When the pool runs out, requests for every other link wait for a free one.
3What do you give up with the Redis counter?Show answerHide
Freshness: counts lag by the flush interval, and a crash between reading and flushing loses that batch. For click counts that is acceptable, and you say so before the interviewer asks.
The code
redirect.bug.ts
Example code. The tested version, with a proof test against real Postgres, lands in the repo.
Star the repo for the runnable version
Each trap in this world ships with the bug, the fix and a proof test that fires concurrent requests at a real database.
Watch the 60-second version
Remember
Whatever you add to the redirect is paid on every click. Count off the path, flush in batches, and say that counts lag.
NextThe vanity alias raceTwo users claim /launch at once, and both are told it is theirs.