Nobody has started this yet — be first.
Business impact
Click counts are this product's core piece of data -- it's the entire reason the async click-logging path exists at all (see this project's README, "Async click logging"). Right now, whenever a link gets multiple clicks close together in time -- which is exactly what a link doing well looks like: shared in a group chat, posted somewhere with real traffic -- some of those clicks silently fail to get counted. Nothing errors, nothing logs a warning, GET /:code/stats just quietly reports a number lower than the true click count, forever. Anyone using click counts to judge which campaign link performed better, or to bill/report on traffic, is working from numbers that undercount exactly the links that matter most -- the popular ones -- and there's no error anywhere to tell them the number is wrong. This is a concurrency/correctness bug at the very heart of the product's core metric, which is why it carries real priority despite never producing a visible error.
Problem
incrementClickCount in src/lib/clickLogger.js now reads the current clicks value, computes +1 in application code, and writes that back -- a classic read-then-write. When multiple redirects for the same short code happen close together, two (or more) of these can both read the same clicks value before either has written its increment back, both compute the same +1 result, and both write the same value -- so N concurrent clicks can produce fewer than N total increments.
Current behavior
50 concurrent calls to incrementClickCount for the same short code can produce a final clicks value far below 50 -- in one captured run, a single successful increment out of 50 concurrent calls, because every call read clicks: 0 before any of them had written back.
Expected behavior
N concurrent clicks on the same short code must always produce exactly +N to that link's clicks count -- never less, regardless of how many happen at the same time. This project's own README documents this exact guarantee under "Async click logging," and tests/concurrency.test.js in the real suite exists specifically to prove it.
Steps to reproduce
// From a Node REPL or script with DATABASE_URL set: const { createLink } = require('./src/lib/linkService'); const { incrementClickCount } = require('./src/lib/clickLogger');
const link = await createLink({ longUrl: 'https://example.com/race' }); await Promise.all(Array.from({ length: 50 }, () => incrementClickCount(link.shortCode)));
const prisma = require('./src/lib/prisma'); const updated = await prisma.link.findUnique({ where: { shortCode: link.shortCode } }); console.log(updated.clicks); // Expected: 50 // Actual: some number less than 50 (varies by run -- that's the nature of a race)
Why this matters
This is the exact anti-pattern this project's own code comments warn about. The doc comment directly above incrementClickCount in src/lib/clickLogger.js explains, in detail, why a single atomic SQL UPDATE ... SET clicks = clicks + 1 is required instead of a read-then-write, using the precise words "read clicks, compute +1, write clicks" to describe the exact pattern that causes lost updates. The current implementation does precisely the thing the comment says not to do -- read the row with prisma.link.findUnique, compute link.clicks + 1 in JavaScript, then write it with prisma.link.update. Between the read and the write, the Node.js event loop can freely interleave other concurrent calls to this same function, so two calls can both read the same starting value before either writes back. This bug is NOT deterministic and won't show up if you only ever call the function once at a time -- it only manifests under real concurrency, which is exactly the scenario a popular link creates.
Suggested approach
Compare the current body of incrementClickCount against the doc comment directly above it in the same file, and against what this project's README says about "Atomic increment, not read-then-write." The fix is to express the increment as a single database statement that Postgres itself serializes, rather than three separate steps (read, compute, write) in application code with an await between each.
Acceptance criteria
Verification
npx jest --config practice-tickets/jest.config.js practice-tickets/tests/07-click-count-lost-updates.test.js
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
$git checkout -b fix/click-count-lost-updatesFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/click-count-lost-updatesSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inOr run the whole stack in Docker instead of the steps above -- docker compose up --build (dev, hot-reloaded via a bind-mounted src/) or docker compose -f docker-compose.prod.yml up --build (prod-style multi-stage build, non-root user). Either way the app container runs prisma migrate deploy on startup, so no separate migration step is needed. Host ports are non-default -- app 3003, Postgres 5436, Redis 6380 -- to avoid clashing with sibling projects in this repo.
The project's own real test suite (npm test, Jest + Supertest against real Postgres/Redis) is separate from the practice tickets below -- run it any time to confirm you haven't broken anything already-working.
Each ticket in practice-tickets/tickets/ (01 through 07) names a dedicated test under practice-tickets/tests/, run via its own Jest project (practice-tickets/jest.config.js, excluded from plain npm test):
# a single ticket
npx jest --config practice-tickets/jest.config.js practice-tickets/tests/01-health-check-false-positive.test.js
# all 7, one at a time, with a pass/fail summary
./practice-tickets/run.sh
Fixing tickets 01, 03, 04, and 07 also turns several pre-existing failures in the real suite (tests/health.test.js, tests/redirect.test.js, tests/concurrency.test.js) back to green -- that's expected, not a coincidence, since the injected bugs live in shared code that suite also exercises. tests/concurrency.test.js specifically fails for two unrelated reasons at once (tickets 04 and 07 both touch code paths it exercises), so fixing only one of the two will not turn it green.
Level 1
Fix a bug
Read existing behaviour, correct it.