A leaderboard needs more than a descending list of numbers. A slower run must not replace a personal best. The name and avatar beside a score need to describe the same entry. Daily standings need a predictable boundary. Typestrix implements those rules in a server-only Redis layer (Upstash's REST client, reachable only from server code), separate from the Postgres table that stores individual tests.
A submission, step by step
Pick a step, or use the arrows, to walk through the diagram.
One test, four possible bests
The ranking score is Math.round(wpm * (accuracy / 100)). An illustrative 100 WPM run at 98% accuracy therefore scores 98. The displayed WPM and accuracy remain separate metadata; the Redis score determines ordering.
Each submission targets four sorted sets: all-time overall, all-time for its mode, daily overall, and daily for its mode. Reading the page covers six boards because both time and words modes exist alongside the overall board for each time frame.
The write uses Redis's greater-than option rather than reading a previous score and comparing it in JavaScript:
p1.zadd(ALL_TIME_KEY, { gt: true, ch: true }, { score, member: userId });
gt keeps an existing member's score from decreasing. New members can still enter the board. ch reports whether the score changed or a member was added. Equal scores do not replace the entry, even if the new run has a different combination of speed and accuracy.
Metadata follows the board that changed
The first pipeline submits the four score writes and sets expiry on the daily sorted sets. Its results decide which hashes receive metadata in a second pipeline. Each hash has a user ID as its field and a record containing the name, rounded WPM, accuracy, mode, and avatar settings as its value.
This matters because a daily best can improve while an all-time best stays higher. Writing one shared metadata record for both boards would attach today's weaker result to the all-time score. Typestrix stores metadata per board and updates only the boards whose score changed.
There is also a per-user SET with nx: true and a ten-second expiry before these pipelines. A submission during that cooldown skips leaderboard work. It does not undo the test already saved in Postgres, but it can skip a better score submitted within those ten seconds. That is an explicit tradeoff in the current implementation.
Six boards, two read pipelines
The read path first batches six reverse ZRANGE commands to obtain the top ten IDs per board. It then batches one HMGET for each nonempty board to fetch those users' metadata.
That makes the number of network phases independent of the number of ranked users: one phase for IDs, one for records. An entirely empty leaderboard needs only the first phase.
Decoded Redis values are narrowed before rendering. Entries without a usable name are skipped. The application does not assume every stored value has the expected shape.
A day means midnight in India
Daily keys contain a date calculated with the fixed UTC+05:30 offset. Both daily ranks and their metadata expire at the next IST midnight. The date is also part of the Next.js data-cache key, so a new day selects a different cached bundle.
Cached bundles have a 1,800-second revalidation interval. A changed best explicitly expires the leaderboard cache tag, allowing the next render to fetch fresh data.
Where the consistency boundary stops
Each sorted-set command protects its own best-score update, but the score and metadata pipelines are separate. A failure between them can leave a rank without matching metadata; overlapping submissions can also race between those phases. The cooldown reduces overlap, but it is not a transaction across the write path.
The test itself is saved before leaderboard submission. A leaderboard error is caught and logged so it does not turn a saved test into a failed save. There is no durable retry queue in this path. A transactional score-and-metadata write or a repair job would be a next step if stronger consistency became necessary.
For the visual side of results, read how the results chart became plain SVG.