Build a Redis Segment Leaderboard
Rank 500K athletes with Redis sorted sets using Strava's architecture.
Introduction
30 Second Summary
Every fitness app with a leaderboard faces the same scaling question: when half a million athletes have run the same trail, how do you instantly answer "what's my rank?" Scanning every entry to find the answer takes longer and longer as the list grows.
In this project, you will build a TypeScript leaderboard engine modeled on Strava's segment architecture. You will benchmark rank queries at 500,000 athletes and see firsthand why Redis sorted sets scale where naive arrays collapse.
What You'll Build
You will query a leaderboard of half a million athletes from your terminal and see your rank, the top 10 fastest, the five athletes nearest you, and your percentile print out in one clean formatted view.
By the end of this project, you'll have:
- A benchmark table you ran yourself, proving that Redis sorted-set rank lookups stay nearly flat at 500K athletes while a naive in-memory array degrades linearly.
- A personal-best update powered by ZADD LT that atomically rejects slower times and only promotes faster ones on the leaderboard.
- A composite segment view that fetches the top 10, your rank, the athletes near you, and your percentile in a single pipelined Redis call.
- Secret Mission: Build time-windowed leaderboards (all-time, this-year, this-month) with TTL-based expiration and cross-window pipelined queries.
Are there any prerequisites?
This project assumes you have Docker and Node.js (version 18 or higher) installed on your machine. You will set up everything else in Step 1.
Before We Start
Before we dive in, take a moment to lock in what you are about to build and why it matters. This project tackles a real ranking problem that surfaces when half a million athletes complete the same segment and you need to answer "what's my rank?" without scanning every single result.
Set Up Your Leaderboard Project
You know what you're building: a Strava-style segment leaderboard engine that ranks 500,000 athletes. Before you can write any leaderboard logic, you need the infrastructure underneath it.
In this step, you'll spin up a Redis server inside a Docker container, scaffold a TypeScript project with the right dependencies, and build the data types and seeding utility that the rest of the project depends on.
In this step, get ready to:
- Start a Redis container and verify TypeScript can connect to it.
- Define the data types for segment efforts and leaderboard entries.
- Build a seeding utility that generates realistic athlete data.
Start Redis in Docker and scaffold the TypeScript project
Redis sorted sets are the core data structure powering your leaderboard. You need a running Redis server to store and query them.
- Start a Redis 7.4 container by running this command:
docker run --name strava-redis -p 6379:6379 -d redis:7.4
What does this command do?
- --name strava-redis gives the container a memorable name you can reference later.
- -p 6379:6379 maps the container's Redis port to your machine so your TypeScript code can connect.
- -d runs the container in the background so you can keep using your terminal.
- redis:7.4 pulls the official Redis image at version 7.4.
- Confirm the container is running by running:
docker ps
You should see strava-redis listed with a status of Up.
Container not starting?
- Make sure Docker Desktop is running. Open it from your applications if it is not.
- If you see a port conflict error, another process is using port 6379. Stop that process or use a different host port: docker run --name strava-redis -p 6380:6379 -d redis:7.4. Update the port in your code later to match.
- If the container name is already taken, remove the old one first: docker rm -f strava-redis.
Why isn't my Docker container starting?
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and initialize a Node.js project by running these commands:
mkdir strava-leaderboard
cd strava-leaderboard
npm init -y
The terminal prints the contents of your new package.json file.
- Install the project dependencies by running these commands:
npm install ioredis@5.11.1
npm install -D tsx @types/node typescript
What are these packages?
- ioredis is the Redis client for Node.js. It handles the connection and sends commands to your Redis server.
- tsx runs TypeScript files directly without a separate compile step.
- typescript and @types/node provide the TypeScript compiler and Node.js type definitions.
Seeing npm errors?
- Check that you're inside the strava-leaderboard folder. Run pwd to confirm your current directory.
- Make sure Node.js is installed by running node --version. You should see version 18 or higher.
Help me fix my npm install errors.
- Create the src directory by running:
mkdir src
Now open the project in your code editor so you can start creating files.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the strava-leaderboard folder on your Desktop, then click Open.
- Create a new file called ping.ts inside the src folder and add this code:
import { Redis } from "ioredis";
async function main() {
// Connect to the Redis container running on localhost
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
// Send a PING command to verify the connection
const result = await redis.ping();
console.log(result);
// Clean up the connection
await redis.quit();
}
main();
What does this code do?
- new Redis({ host: "127.0.0.1", port: 6379 }) connects to the Redis server running inside your Docker container.
- redis.ping() sends a PING command. If Redis is reachable, it responds with PONG.
- redis.quit() closes the connection cleanly so the script can exit.
- Save src/ping.ts.
Before you run this, what do you think Redis will respond to a PING command?
- Test the connection to Redis by running:
npx tsx src/ping.ts
You should see PONG printed to the terminal. Your TypeScript code is talking to your Dockerized Redis.
Not seeing PONG?
- Run docker ps to confirm your strava-redis container is still running.
- If you used a different host port (like 6380), update the port value in ping.ts to match.
- Check for typos in the import statement. It should be import { Redis } from "ioredis".
Why can't my TypeScript code connect to Redis?
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/ping.ts.
ⓧ I'd like to double check the full code
import { Redis } from "ioredis";
async function main() {
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
const result = await redis.ping();
console.log(result);
await redis.quit();
}
main();
Create the leaderboard data model and seeding utility
Every athlete's run on a Strava segment produces an effort with an elapsed time, athlete ID, activity ID, and date. You need TypeScript interfaces that capture this shape so every file in the project speaks the same language.
- Create a new file at src/types.ts and add this code:
// Represents a single athlete's effort on a segment
export interface SegmentEffort {
athleteId: string;
elapsedTimeSeconds: number;
activityId: string;
date: string;
segmentName: string;
}
// A ranked entry on the leaderboard
export interface LeaderboardEntry {
athleteId: string;
elapsedTimeSeconds: number;
rank: number;
}
// Holds timing results from the benchmark harness
export interface BenchmarkResult {
label: string;
median: number;
p95: number;
p99: number;
iterations: number;
}
What are these types?
- SegmentEffort captures everything about a single run: which athlete, how long it took, and which segment.
- LeaderboardEntry is what you return from a leaderboard query: the athlete, their time, and their rank.
- BenchmarkResult stores timing data (median, p95, p99) from your benchmark harness. You'll build that harness in the next step.
- Save src/types.ts.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/types.ts.
ⓧ I'd like to double check the full code
export interface SegmentEffort {
athleteId: string;
elapsedTimeSeconds: number;
activityId: string;
date: string;
segmentName: string;
}
export interface LeaderboardEntry {
athleteId: string;
elapsedTimeSeconds: number;
rank: number;
}
export interface BenchmarkResult {
label: string;
median: number;
p95: number;
p99: number;
iterations: number;
}
To test a leaderboard at scale, you need a way to generate thousands of realistic athlete efforts on demand.
- Create a new file at src/seed.ts and add this code:
import { SegmentEffort } from "./types.js";
// Generate realistic segment efforts with random elapsed times
export function generateEfforts(
count: number,
segmentName: string
): SegmentEffort[] {
const efforts: SegmentEffort[] = [];
for (let i = 0; i < count; i++) {
efforts.push({
athleteId: `athlete-${i}`,
// Random time between 180-600 seconds (3-10 minute segments)
elapsedTimeSeconds: 180 + Math.random() * 420,
activityId: `activity-${i}`,
// Random date within the past year
date: new Date(
Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000
).toISOString(),
segmentName,
});
}
return efforts;
}
What does this function do?
generateEfforts creates an array of simulated segment efforts. Each athlete gets a unique ID, a random elapsed time between 180 and 600 seconds (3 to 10 minutes), and a random date from the past year.
The 180-600 second range mimics a real Strava segment. Short hill climbs might take 3 minutes. Longer loops might take 10.
- Save src/seed.ts.
Verify the seed utility produces realistic data
The seeding function is ready, but you should verify it produces sensible output before scaling it to 500,000 athletes.
- In src/seed.ts, add this code below the generateEfforts function:
// Run directly to verify the function produces realistic data
if (process.argv[1]?.includes("seed")) {
const efforts = generateEfforts(10, "highland-climb");
efforts.forEach((e) =>
console.log(
`${e.athleteId}: ${e.elapsedTimeSeconds.toFixed(1)}s (${e.activityId})`
)
);
}
What does this test block do?
The if (process.argv[1]?.includes("seed")) guard runs the test only when you execute seed.ts directly. When other files import generateEfforts, this block stays silent.
- Save src/seed.ts.
What range of elapsed times do you expect to see for a 3-to-10 minute segment?
- Run the seed utility by running:
npx tsx src/seed.ts
You should see 10 athletes printed, each with an elapsed time between 180 and 600 seconds. Each line shows the athlete ID, their time, and their activity ID.
Not seeing 10 efforts?
- Check that the if block is outside the generateEfforts function, not inside it. It should be at the bottom of the file after the closing brace.
- Make sure the import at the top reads from "./types.js" with the .js extension. TypeScript module resolution requires this for ESM-style imports.
Why isn't my seed utility printing output?
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/seed.ts.
ⓧ I'd like to double check the full code
import { SegmentEffort } from "./types.js";
export function generateEfforts(
count: number,
segmentName: string
): SegmentEffort[] {
const efforts: SegmentEffort[] = [];
for (let i = 0; i < count; i++) {
efforts.push({
athleteId: `athlete-${i}`,
elapsedTimeSeconds: 180 + Math.random() * 420,
activityId: `activity-${i}`,
date: new Date(
Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000
).toISOString(),
segmentName,
});
}
return efforts;
}
if (process.argv[1]?.includes("seed")) {
const efforts = generateEfforts(10, "highland-climb");
efforts.forEach((e) =>
console.log(
`${e.athleteId}: ${e.elapsedTimeSeconds.toFixed(1)}s (${e.activityId})`
)
);
}
Your Redis server is running, your TypeScript project is scaffolded, and you have a utility that can generate half a million athletes on demand. Next up, you'll build the naive leaderboard and watch it struggle at scale.
Build the Naive Leaderboard and Hit the Wall
Your project is scaffolded, Redis is running, and your seed utility can generate half a million simulated athletes. Now for the real question: how do you answer "what's my rank?" among 500,000 athletes?
The most intuitive approach is to sort everyone by time and scan the list until you find your name. This is the approach Strava originally used. In this step, you'll build exactly that, measure how long it takes at increasing scale, and watch the approach hit a wall.
In this step, get ready to:
- Implement a sorted-array leaderboard with top-N, rank, and around-me queries.
- Build a reusable benchmark harness that measures median, p95, and p99 timings.
- Run benchmarks at 10K, 100K, and 500K athletes and observe the O(N) performance wall.
Implement the naive sorted-array leaderboard
The simplest way to rank athletes is to store all their times in an array, sort it, and scan from the top until you find the athlete you're looking for. This is O(N) because the scan touches every element in the worst case.
- Create a new file src/naive-leaderboard.ts and add the class declaration with the seed and topN methods by pasting this code:
import { SegmentEffort, LeaderboardEntry } from "./types.js";
export class NaiveLeaderboard {
private efforts: SegmentEffort[] = [];
// Store all efforts sorted by elapsed time (fastest first)
seed(efforts: SegmentEffort[]) {
this.efforts = [...efforts];
this.efforts.sort((a, b) => a.elapsedTimeSeconds - b.elapsedTimeSeconds);
}
// Return the N fastest athletes from the sorted array
topN(n: number): LeaderboardEntry[] {
return this.efforts.slice(0, n).map((e, i) => ({
athleteId: e.athleteId,
elapsedTimeSeconds: e.elapsedTimeSeconds,
rank: i + 1,
}));
}
What does this code do?
The class stores segment efforts in a plain array. When you call seed(), it copies the efforts and sorts them by elapsed time ascending, so the fastest athlete is at index 0.
The topN() method slices the first N entries from the sorted array. Because the array is pre-sorted, fetching the top 10 is cheap. The expensive part comes next.
- Now add the getRank and aroundMe methods below the topN method, still inside the class:
// Linear scan through every athlete to find one's rank — O(N)
getRank(athleteId: string): LeaderboardEntry | null {
for (let i = 0; i < this.efforts.length; i++) {
if (this.efforts[i].athleteId === athleteId) {
return {
athleteId,
elapsedTimeSeconds: this.efforts[i].elapsedTimeSeconds,
rank: i + 1,
};
}
}
return null;
}
// Find the athlete's position, then slice a window around them
aroundMe(athleteId: string, window: number): LeaderboardEntry[] {
const index = this.efforts.findIndex((e) => e.athleteId === athleteId);
if (index === -1) return [];
const start = Math.max(0, index - window);
const end = Math.min(this.efforts.length, index + window + 1);
return this.efforts.slice(start, end).map((e, i) => ({
athleteId: e.athleteId,
elapsedTimeSeconds: e.elapsedTimeSeconds,
rank: start + i + 1,
}));
}
}
Why is getRank O(N)?
Even though the array is sorted, getRank does a linear scan from index 0 until it finds the matching athlete ID. In the worst case it checks every single element. With 500,000 athletes, that's 500,000 comparisons per query.
The aroundMe method has the same problem. It uses findIndex (another linear scan) to locate the athlete, then slices a window of neighbors around that position.
- Save src/naive-leaderboard.ts.
- Verify the file compiles by running:
npx tsx src/naive-leaderboard.ts
The command should exit silently with no errors. If you see a syntax or import error, it means something in the file needs fixing before you move on.
Seeing an import error?
- Check that the import path ends with .js. TypeScript with ESM resolution requires the .js extension even when the source file is .ts.
- Make sure the closing brace } for the class is present after the aroundMe method.
- Why isn't my naive-leaderboard.ts compiling?
✔️ Awesome, I've got everything!
Great. Double check you've saved src/naive-leaderboard.ts.
ⓧ I'd like to double check the full code
Your src/naive-leaderboard.ts file is built from two code blocks above. Scroll up and confirm each one is present in this order:
- The import, class declaration, seed() method, and topN() method.
- The getRank() method, aroundMe() method, and the closing } for the class.
Build the benchmark harness
A leaderboard that "feels slow" is not evidence. You need numbers. The benchmark harness will run any function many times and report how long it takes at the median, 95th percentile, and 99th percentile.
- Create a new file src/benchmark.ts and add the measure function by pasting this code:
import { BenchmarkResult } from "./types.js";
export async function measure(
label: string,
fn: () => Promise<void> | void,
iterations: number = 100
): Promise<BenchmarkResult> {
// Warm up with 3 throwaway runs (JIT compilation, cache warming)
for (let i = 0; i < 3; i++) {
await fn();
}
// Collect measured iterations
const times: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await fn();
const end = performance.now();
times.push(end - start);
}
// Sort times and extract percentiles
times.sort((a, b) => a - b);
const median = times[Math.floor(times.length / 2)];
const p95 = times[Math.floor(times.length * 0.95)];
const p99 = times[Math.floor(times.length * 0.99)];
return { label, median, p95, p99, iterations };
}
What does this code do?
- The 3 warmup runs let the JavaScript engine's JIT compiler optimize the function before you start measuring. Without this, the first few runs would be artificially slow.
- Each measured iteration is timed with performance.now(), which gives sub-millisecond precision.
- The median shows typical performance. The p95 and p99 show worst-case spikes that matter in production systems.
- Now add the printResults function below the measure function in the same file:
export function printResults(results: BenchmarkResult[]) {
// Format results as a readable table with aligned columns
console.log("\n" + "-".repeat(70));
console.log(
`${"Operation".padEnd(30)} ${"Median".padStart(10)} ${"P95".padStart(10)} ${"P99".padStart(10)}`
);
console.log("-".repeat(70));
for (const r of results) {
console.log(
`${r.label.padEnd(30)} ${(r.median.toFixed(3) + "ms").padStart(10)} ${(r.p95.toFixed(3) + "ms").padStart(10)} ${(r.p99.toFixed(3) + "ms").padStart(10)}`
);
}
console.log("-".repeat(70) + "\n");
}
What does this code do?
The printResults function takes an array of benchmark results and prints them as a formatted table with aligned columns. You'll reuse this harness throughout the project to compare different leaderboard implementations.
- Save src/benchmark.ts.
- Verify the file compiles by running:
npx tsx src/benchmark.ts
The command should exit silently with no errors. The harness exports functions but doesn't execute anything on its own.
Seeing a syntax error?
- Check that the template literal backticks in printResults are not replaced with regular quotes. The string interpolation with ${} requires backticks.
- Make sure the import path uses "./types.js" with the .js extension.
- Why isn't my benchmark.ts compiling?
✔️ Awesome, I've got everything!
Great. Double check you've saved src/benchmark.ts.
ⓧ I'd like to double check the full code
Your src/benchmark.ts file is built from two code blocks above. Scroll up and confirm each one is present in this order:
- The import and the measure() function (warmup, timed loop, percentile calculation).
- The printResults() function (formatted table output).
Run the naive benchmark at increasing scale
Now you'll wire up the naive leaderboard and the benchmark harness. The script will seed the leaderboard at three sizes and measure how long getRank takes at each one.
- Create a new file src/benchmark-naive.ts and paste this code:
import { NaiveLeaderboard } from "./naive-leaderboard.js";
import { generateEfforts } from "./seed.js";
import { measure, printResults } from "./benchmark.js";
import { BenchmarkResult } from "./types.js";
async function main() {
// Test at three increasing dataset sizes
const sizes = [10_000, 100_000, 500_000];
const results: BenchmarkResult[] = [];
for (const size of sizes) {
console.log(`Seeding naive leaderboard with ${size.toLocaleString()} athletes...`);
const efforts = generateEfforts(size, "highland-climb");
const lb = new NaiveLeaderboard();
lb.seed(efforts);
const targetId = `athlete-${Math.floor(size / 2)}`;
const result = await measure(
`Naive getRank (${(size / 1000).toFixed(0)}K)`,
() => {
lb.getRank(targetId);
},
100
);
results.push(result);
console.log(` ${result.label}: median ${result.median.toFixed(3)}ms`);
}
printResults(results);
}
main();
What does this code do?
- For each dataset size (10K, 100K, 500K), it generates that many athletes, seeds the naive leaderboard, and picks a mid-ranked athlete as the lookup target.
- The measure function runs getRank 100 times at each size, collecting timing data. After all three sizes, it prints a comparison table.
- If the rank lookup is truly O(N), you should see the median time grow roughly proportionally as the dataset gets bigger.
- Save src/benchmark-naive.ts.
Before you run the benchmark, think about what you expect to see. The dataset grows from 10K to 100K (10x larger) to 500K (50x larger). If getRank is truly O(N), how should the median time change as the dataset grows?
- Run the benchmark by executing:
npx tsx src/benchmark-naive.ts
The script takes a few seconds to seed and benchmark at each size. When it finishes, you'll see a table with the median, p95, and p99 timings for each dataset size.
Look at the median column. The time at 100K should be roughly 10x the time at 10K. The time at 500K should be roughly 50x the time at 10K. That proportional growth is O(N) in action. This is the wall Strava hit.
Why does this matter at Strava's scale?
Your benchmark is running against an in-memory array on your local machine. Strava has millions of athletes and thousands of segments. If every "what's my rank?" query requires a linear scan, the response time grows linearly with the number of athletes.
Note your specific numbers. They form the "before" column in the comparison table you'll build in the next step using Redis sorted sets.
Benchmark script crashing or hanging?
- If you see an out-of-memory error at 500K, make sure you're not running other memory-heavy applications. The 500K array needs a few hundred MB of RAM.
- If the script seems frozen, give it time. Seeding 500,000 athletes and running 100 benchmark iterations takes several seconds.
- Why is my naive benchmark crashing or taking too long?
You've now felt the O(N) wall firsthand. Your own numbers prove that scanning a sorted array scales linearly with the number of athletes. Next up, you'll rebuild this leaderboard using Redis sorted sets and watch those same rank queries drop to near-constant time.
Rebuild with Redis Sorted Sets
Your naive benchmark painted a clear picture. As the athlete count grew, rank-lookup time grew right alongside it. That O(N) scaling wall is exactly what Strava hit when their segment leaderboards outgrew simple sorted arrays.
Redis sorted sets use a different data structure under the hood. A skip list with span counters computes rank by summing spans along a search path, visiting only O(log N) nodes instead of scanning every element. In this step, you will rebuild the leaderboard on Redis sorted sets and benchmark it against the naive approach to see the difference in your own numbers.
In this step, get ready to:
- Implement a Redis-backed leaderboard class using sorted sets and companion hashes.
- Add the around-me query that shows athletes ranked near a given athlete.
- Benchmark Redis vs naive side-by-side to see the O(log N) difference empirically.
Implement the Redis-backed leaderboard
The naive leaderboard stored efforts in a plain JavaScript array. Redis sorted sets store the same data in a skip list, where each member (athlete ID) has a score (elapsed time in seconds). The lowest score gets rank 0, which maps to the fastest athlete.
Each athlete's effort metadata (activity ID, date, segment name) goes into a companion hash keyed by athlete ID. The sorted set handles ranking. The hash handles metadata lookups.
- Create a new file src/redis-leaderboard.ts and add the class declaration, constructor, key helpers, and seed method by copying this code:
import { Redis } from "ioredis";
import { SegmentEffort, LeaderboardEntry } from "./types.js";
export class RedisLeaderboard {
constructor(
private redis: Redis,
private segmentName: string
) {}
private get lbKey() {
return `segment:${this.segmentName}:lb:all-time`;
}
private effortKey(athleteId: string) {
return `segment:${this.segmentName}:effort:${athleteId}`;
}
async seed(efforts: SegmentEffort[]) {
const batchSize = 10_000;
for (let i = 0; i < efforts.length; i += batchSize) {
const batch = efforts.slice(i, i + batchSize);
const pipeline = this.redis.pipeline();
for (const effort of batch) {
pipeline.zadd(this.lbKey, effort.elapsedTimeSeconds.toString(), effort.athleteId);
pipeline.hset(this.effortKey(effort.athleteId), {
activityId: effort.activityId,
date: effort.date,
segmentName: effort.segmentName,
});
}
await pipeline.exec();
}
}
What does this code do?
The RedisLeaderboard class wraps an ioredis connection and a segment name. Two private helpers build Redis key strings so every method uses a consistent naming pattern like segment:highland-climb:lb:all-time.
The seed method uses pipelining to batch Redis commands. Instead of sending 500,000 individual network round-trips, it groups commands into batches of 10,000 and sends each batch as a single pipeline. For each athlete, it issues two commands: ZADD to add the athlete to the sorted set (score = elapsed time), and HSET to store metadata in a companion hash.
- Save src/redis-leaderboard.ts.
- Confirm the file was created by running:
ls src/redis-leaderboard.ts
You should see src/redis-leaderboard.ts printed in the output.
File not found?
Make sure you created the file inside the src/ directory, not in the project root. If needed, move the file with mv redis-leaderboard.ts src/.
help me find where my file ended up
- Add the topN and getRank query methods below the seed method in src/redis-leaderboard.ts:
async topN(n: number): Promise<LeaderboardEntry[]> {
const results = await this.redis.zrange(this.lbKey, 0, n - 1, "WITHSCORES");
const entries: LeaderboardEntry[] = [];
for (let i = 0; i < results.length; i += 2) {
entries.push({
athleteId: results[i],
elapsedTimeSeconds: parseFloat(results[i + 1]),
rank: entries.length + 1,
});
}
return entries;
}
async getRank(athleteId: string): Promise<LeaderboardEntry | null> {
const pipeline = this.redis.pipeline();
pipeline.zrank(this.lbKey, athleteId);
pipeline.zscore(this.lbKey, athleteId);
const results = await pipeline.exec();
if (!results) return null;
const rank = results[0][1] as number | null;
const score = results[1][1] as string | null;
if (rank === null || score === null) return null;
return {
athleteId,
elapsedTimeSeconds: parseFloat(score),
rank: rank + 1,
};
}
Why is getRank O(log N) instead of O(N)?
The naive leaderboard scans every element in the array to find an athlete's position. That is O(N). Redis ZRANK works differently. The sorted set's skip list stores a span count on each forward pointer. When Redis walks the skip list to find an athlete, it sums those spans along the search path. The total is the rank.
This span-accumulation trick means ZRANK visits only about log2(N) nodes. At 500,000 athletes, that is roughly 19 node hops instead of 500,000 comparisons. Both the naive array and Redis are in-memory. The data structure is the difference.
topN uses ZRANGE to fetch the first N members by ascending score (lowest elapsed time = fastest = rank 1). The WITHSCORES flag returns each member's score alongside it.
- Save src/redis-leaderboard.ts.
Before continuing, confirm the Docker container from Step 1 is still running and responsive by running:
docker exec strava-redis redis-cli PING
You should see PONG printed. This confirms your Redis instance is healthy and ready for the 500K-athlete seeding that comes later.
Container not running?
If you see an error mentioning that the container is not running, restart it by running docker start strava-redis and then try the PING command again.
help me get my Redis Docker container running again
Add the around-me query and complete the class
When you view a segment on Strava, you see your rank plus the athletes immediately above and below you. This around-me view gives context that a bare rank number cannot.
- Add the aroundMe method below getRank in src/redis-leaderboard.ts:
async aroundMe(
athleteId: string,
window: number
): Promise<LeaderboardEntry[]> {
const rank = await this.redis.zrank(this.lbKey, athleteId);
if (rank === null) return [];
const start = Math.max(0, rank - window);
const end = rank + window;
const results = await this.redis.zrange(
this.lbKey,
start,
end,
"WITHSCORES"
);
const entries: LeaderboardEntry[] = [];
for (let i = 0; i < results.length; i += 2) {
entries.push({
athleteId: results[i],
elapsedTimeSeconds: parseFloat(results[i + 1]),
rank: start + entries.length + 1,
});
}
return entries;
}
What does aroundMe do?
It first calls ZRANK to find the athlete's 0-indexed position. Then it calculates a window (e.g. 5 above and 5 below) and calls ZRANGE to fetch that slice. Because the sorted set is ordered by score (elapsed time), the slice returns athletes ranked immediately around the target athlete.
The class also needs three utility methods: one for personal-best updates (used in the next step), one for counting total athletes, and one for cleaning up test data between benchmark runs.
- Add the following utility methods below aroundMe and close the class with a final }:
async updateEffort(effort: SegmentEffort): Promise<boolean> {
const changed = await this.redis.zadd(
this.lbKey,
"LT",
"CH",
effort.elapsedTimeSeconds.toString(),
effort.athleteId
);
if (changed > 0) {
await this.redis.hset(this.effortKey(effort.athleteId), {
activityId: effort.activityId,
date: effort.date,
segmentName: effort.segmentName,
});
}
return changed > 0;
}
async getTotalAthletes(): Promise<number> {
return this.redis.zcard(this.lbKey);
}
async cleanup() {
const keys = await this.redis.keys(`segment:${this.segmentName}:*`);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}
What do these utility methods do?
- updateEffort uses ZADD with the LT flag. Since lower elapsed time = faster = better, LT ensures the score only updates when the new time is less than the current one. You will explore this in depth in the next step.
- getTotalAthletes calls ZCARD to count members in the sorted set in O(1).
- cleanup deletes all keys matching the segment prefix so benchmark runs start fresh.
- Save src/redis-leaderboard.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before continuing.
ⓧ I'd like to double check the full code
Since src/redis-leaderboard.ts is a longer file, scroll up and confirm these four sections are present in order:
- Imports, class declaration, constructor, lbKey getter, and effortKey helper.
- seed method that pipelines ZADD and HSET in batches of 10,000.
- topN (using ZRANGE with WITHSCORES) and getRank (using ZRANK + ZSCORE in a pipeline).
- aroundMe (ZRANK then ZRANGE window), updateEffort (ZADD LT CH), getTotalAthletes (ZCARD), and cleanup. Class closing brace at the bottom.
Benchmark Redis vs naive side-by-side
The leaderboard class is ready. Now you will run the same getRank query at 10K, 100K, and 500K athletes on both implementations and print the results in one table. The numbers will tell the story.
- Create a new file src/benchmark-redis.ts and add the imports, Redis connection, and the seeding loop by copying this code:
import { Redis } from "ioredis";
import { RedisLeaderboard } from "./redis-leaderboard.js";
import { NaiveLeaderboard } from "./naive-leaderboard.js";
import { generateEfforts } from "./seed.js";
import { measure, printResults } from "./benchmark.js";
import { BenchmarkResult } from "./types.js";
async function main() {
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
const sizes = [10_000, 100_000, 500_000];
const results: BenchmarkResult[] = [];
for (const size of sizes) {
const segmentName = `bench-${size}`;
const lb = new RedisLeaderboard(redis, segmentName);
const naive = new NaiveLeaderboard();
console.log(`Seeding ${size.toLocaleString()} athletes...`);
const efforts = generateEfforts(size, segmentName);
await lb.cleanup();
await lb.seed(efforts);
naive.seed(efforts);
const targetId = `athlete-${Math.floor(size / 2)}`;
const sizeLabel = `${(size / 1000).toFixed(0)}K`;
What does this setup do?
The script loops through three dataset sizes: 10K, 100K, and 500K. For each size, it generates the same set of efforts and seeds both the naive array and the Redis sorted set. This ensures both implementations rank the same data, making the comparison fair.
cleanup runs before each seed to clear leftover data from previous runs.
- Add the benchmark measurements and result printing below the sizeLabel line in src/benchmark-redis.ts:
// Naive: O(N) linear scan through an in-memory sorted array
const naiveResult = await measure(
`Naive getRank (${sizeLabel})`,
() => {
naive.getRank(targetId);
},
100
);
results.push(naiveResult);
// Redis: O(log N) skip-list traversal with span accumulation
const redisResult = await measure(
`Redis getRank (${sizeLabel})`,
async () => {
await lb.getRank(targetId);
},
100
);
results.push(redisResult);
console.log(
` Naive: ${naiveResult.median.toFixed(3)}ms | Redis: ${redisResult.median.toFixed(3)}ms`
);
await lb.cleanup();
}
printResults(results);
await redis.quit();
}
main();
What does the benchmark measure?
For each dataset size, both implementations answer the same question: what is the rank of an athlete near the middle of the leaderboard? The measure harness runs this query 100 times per implementation (after 3 warmup runs) and records the median, p95, and p99 times.
Both approaches are in-memory. The naive array lives in Node.js memory. Redis lives in container memory. The difference is not about memory vs disk. It is about data structure: a linear scan (O(N)) vs a skip list with span counters (O(log N)).
- Save src/benchmark-redis.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before running the benchmark.
ⓧ I'd like to double check the full code
Scroll up and confirm these two sections are present in order in src/benchmark-redis.ts:
- Imports, main function declaration, Redis connection, sizes array, and the for-loop opening through sizeLabel.
- Naive and Redis measure calls, console.log comparison line, lb.cleanup(), loop closing brace, printResults, redis.quit(), and main() call at the bottom.
Before you run the benchmark, think about what you expect. The naive column already showed times growing with dataset size. Do you expect the Redis column to grow at the same rate, a slower rate, or stay nearly flat?
- Run the side-by-side benchmark by running:
npx tsx src/benchmark-redis.ts
The script takes a minute or two to seed and benchmark all three sizes. You should see a formatted table with six rows: three Naive rows and three Redis rows. The naive median column grows roughly in proportion with dataset size. The Redis median column stays nearly flat across all three sizes.
What do the numbers prove?
Your numbers make the O(N) vs O(log N) scaling behavior visible. The naive leaderboard's getRank scans more elements as N grows, so its time increases proportionally. Redis ZRANK walks the skip list and accumulates span counts, visiting roughly log2(N) nodes regardless of dataset size.
At 500K athletes, log2(500,000) is about 19. That is 19 node hops vs 500,000 comparisons. Your benchmark table is the empirical proof that the data structure is the difference, not just being in-memory.
Benchmark hanging or erroring?
- If you see a connection refused error, make sure the strava-redis container is running. Run docker start strava-redis and try again.
- If the script seems to hang at 500K, give it time. Seeding half a million entries takes a few seconds, and 100 benchmark iterations add up.
- If you see a TypeScript import error, check that src/redis-leaderboard.ts exists and has no syntax errors. A missing closing brace is the most common issue.
help me debug my benchmark script
You now have hard numbers proving that Redis sorted sets scale where sorted arrays do not. Next up, you will put the updateEffort method to work and see how ZADD LT enforces best-time-wins semantics automatically.
Implement Personal-Best Updates with ZADD LT
On Strava, your leaderboard position represents your personal best on a segment. If you re-run the segment on a bad day and record a slower time, your ranking should stay the same.
Without best-time-wins logic, a slower re-run would overwrite your personal record. Your RedisLeaderboard class already has an updateEffort method that solves this with ZADD LT. In this step, you will prove the behavior with a demonstration script and benchmark the update path at 500K scale.
In this step, get ready to:
- Understand how ZADD LT enforces best-time-wins semantics atomically.
- Build a demonstration script that proves slower times are rejected and faster times update the rank.
- Benchmark 1,000 ZADD LT operations against a 500K leaderboard.
Understand the ZADD LT update logic
Your RedisLeaderboard class already has an updateEffort method that uses two Redis flags to enforce personal bests. Open src/redis-leaderboard.ts and scroll down to the updateEffort method:
async updateEffort(effort: SegmentEffort): Promise<boolean> {
const changed = await this.redis.zadd(
this.lbKey,
"LT",
"CH",
effort.elapsedTimeSeconds.toString(),
effort.athleteId
);
if (changed > 0) {
await this.redis.hset(this.effortKey(effort.athleteId), {
activityId: effort.activityId,
date: effort.date,
segmentName: effort.segmentName,
});
}
return changed > 0;
}
What does ZADD LT CH do?
Two flags make this work. LT means "only update an existing member if the new score is less than the current score." Since the score is elapsed time in seconds, a lower score means a faster run. LT atomically rejects slower times without any read-then-write logic.
CH changes the return value from "number of new members added" to "number of members changed (added or updated)." This lets the code check whether the score actually changed before updating the companion hash with new effort metadata.
Why LT instead of GT?
It depends on what the score represents. If higher is better (like points in a game), you would use ZADD GT to keep only the highest score. In Strava's domain, lower elapsed time equals a faster run, so ZADD LT keeps only the fastest time.
Seed a small leaderboard and check an athlete's rank
Now you will build a script that proves this behavior with visible before-and-after output.
- Create a new file src/personal-best.ts and paste the following code:
import { Redis } from "ioredis";
import { RedisLeaderboard } from "./redis-leaderboard.js";
import { generateEfforts } from "./seed.js";
import { measure, printResults } from "./benchmark.js";
async function main() {
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
const lb = new RedisLeaderboard(redis, "pb-test");
await lb.cleanup();
// Seed a small leaderboard with 100 athletes
console.log("Seeding 100 athletes...");
const efforts = generateEfforts(100, "pb-test");
await lb.seed(efforts);
// Check athlete-50's initial rank
const targetId = "athlete-50";
const before = await lb.getRank(targetId);
console.log(
`\nBefore: ${targetId} is rank #${before?.rank} with ${before?.elapsedTimeSeconds.toFixed(1)}s`
);
await lb.cleanup();
await redis.quit();
}
main();
What does this code do?
This seeds a 100-athlete leaderboard for a segment called pb-test, then looks up athlete-50's initial rank. Each athlete gets a random elapsed time between 180 and 600 seconds, so athlete-50's rank will vary on each run.
- Save src/personal-best.ts.
- Run the script to see the initial rank by running:
npx tsx src/personal-best.ts
You should see output showing athlete-50's rank and elapsed time. Note the rank number for what comes next.
Seeing a connection error?
Make sure your strava-redis Docker container is still running. Restart it with docker start strava-redis if needed.
Help me debug my Redis connection error in personal-best.ts
Add the slower and faster time tests
The real test is what happens when the same athlete submits a new effort. A slower time should be rejected. A faster time should update their rank.
- In src/personal-best.ts, find this line:
await lb.cleanup();
- Add the following code above that line to test both a slower and a faster time submission:
// Submit a slower time (higher seconds = worse)
const slowerTime = (before?.elapsedTimeSeconds ?? 300) + 60;
const slowerUpdated = await lb.updateEffort({
athleteId: targetId,
elapsedTimeSeconds: slowerTime,
activityId: "activity-slower",
date: new Date().toISOString(),
segmentName: "pb-test",
});
const afterSlower = await lb.getRank(targetId);
console.log(
`Submitted ${slowerTime.toFixed(1)}s (slower) -> updated: ${slowerUpdated} -> rank #${afterSlower?.rank} (${afterSlower?.elapsedTimeSeconds.toFixed(1)}s)`
);
// Submit a faster time (lower seconds = better)
const fasterTime = (before?.elapsedTimeSeconds ?? 300) - 30;
const fasterUpdated = await lb.updateEffort({
athleteId: targetId,
elapsedTimeSeconds: fasterTime,
activityId: "activity-faster",
date: new Date().toISOString(),
segmentName: "pb-test",
});
const afterFaster = await lb.getRank(targetId);
console.log(
`Submitted ${fasterTime.toFixed(1)}s (faster) -> updated: ${fasterUpdated} -> rank #${afterFaster?.rank} (${afterFaster?.elapsedTimeSeconds.toFixed(1)}s)`
);
What does this code do?
The first block submits a time that is 60 seconds slower than the athlete's current personal best. Because ZADD LT only updates when the new score is less than the current one, this slower time (a higher number) gets rejected. The rank and time stay unchanged.
The second block submits a time that is 30 seconds faster. This time the score IS lower, so ZADD LT accepts the update. The athlete's rank should improve.
Before you run this, do you think the slower time submission will return true or false for updated?
- Save src/personal-best.ts and run it again:
npx tsx src/personal-best.ts
The output should confirm your prediction. The slower time shows updated: false with the rank and time unchanged. The faster time shows updated: true with an improved rank.
Both submissions showing updated: false?
Check that the faster time line uses - 30 (subtracting to get a lower elapsed time) and not + 30. A lower number of seconds means a faster run.
Help me debug why both ZADD LT updates show false
Benchmark the update path at 500K scale
The demonstration proves correctness with 100 athletes. Now you need to confirm that updates scale the same way rank queries do.
- In src/personal-best.ts, find the await lb.cleanup(); line near the bottom of the main function (after the faster time log).
- Add the following benchmark code above that line:
// Benchmark updates at scale
console.log("\nBenchmarking ZADD LT at 500K scale...");
await lb.cleanup();
const bigEfforts = generateEfforts(500_000, "pb-bench");
await lb.seed(bigEfforts);
const updateLb = new RedisLeaderboard(redis, "pb-bench");
const updateResult = await measure(
"ZADD LT update (500K)",
async () => {
await updateLb.updateEffort({
athleteId: `athlete-${Math.floor(Math.random() * 500_000)}`,
elapsedTimeSeconds: 180 + Math.random() * 420,
activityId: "bench-activity",
date: new Date().toISOString(),
segmentName: "pb-bench",
});
},
1000
);
printResults([updateResult]);
What does this code do?
This seeds a fresh 500K-athlete leaderboard and then runs 1,000 ZADD LT operations against it, each targeting a random athlete with a random time. The measure function from your benchmark harness collects the median, p95, and p99 timings.
Because ZADD inserts and updates within the skip list, each operation is O(log N). You should see update times in the same range as the getRank times you measured in the previous step.
- Also update the closing cleanup section to clean up the benchmark segment. Find the existing await lb.cleanup(); and await redis.quit(); lines at the bottom and replace them with:
await lb.cleanup();
await updateLb.cleanup();
await redis.quit();
Before you run this, think about how you expect the ZADD LT update times to compare against the getRank times from the previous step. Should they be similar, faster, or slower?
- Save src/personal-best.ts and run the full script:
npx tsx src/personal-best.ts
You should see the before/after demonstration followed by a benchmark table. The seeding takes a few seconds for 500K athletes. The update median should be in the sub-millisecond range, confirming that ZADD LT is O(log N) just like ZRANK.
Seeing a ReferenceError for updateLb?
Make sure the benchmark code block (where updateLb is created) is placed above the cleanup section. The await updateLb.cleanup() call at the bottom needs updateLb to already be defined.
Help me fix a ReferenceError in my personal-best benchmark script
✔️ Awesome, I've got everything!
Great. Double check you have saved src/personal-best.ts.
ⓧ I'd like to double check the full code
Scroll up and confirm these three sections are present in your src/personal-best.ts, in order:
- Imports, Redis connection, seed 100 athletes, initial rank query for athlete-50.
- Slower time test (+ 60 seconds) followed by faster time test (- 30 seconds), each calling updateEffort and printing the result.
- Benchmark block seeding 500K athletes and measuring 1,000 ZADD LT updates, followed by cleanup for both lb and updateLb.
You now have a personal-best update mechanism that atomically enforces best-time-wins semantics at O(log N) speed. Next up, you will compose all four query patterns into a single pipelined Strava-style segment view.
Query Like Strava: Putting It All Together
Your leaderboard can now rank athletes, compute neighborhoods, and enforce personal bests. Each of those operations works on its own, but Strava does not fire them one at a time.
When you open a segment page on Strava, you see the top 10, your rank, your percentile, and the athletes immediately around you. All of that arrives in a single request. Redis pipelining makes this possible by batching multiple commands into one network round-trip. In this step, you will compose all your queries into a single pipelined call and benchmark the result.
In this step, get ready to:
- Build a composite query that fetches top-10, my-rank, around-me, and athlete count in a single pipeline.
- Calculate and display a percentile ranking from the pipelined results.
- Benchmark the composite query at 500K athletes to see real timing numbers.
Build the composite Strava query
Instead of calling ZRANGE, ZRANK, ZSCORE, and ZCARD as separate round-trips, a pipeline queues them all and sends them to Redis in one shot. The server processes them in order and returns all results at once.
- Create src/strava-query.ts and add the imports plus the first half of the getSegmentView function by copying the following code:
import { Redis } from "ioredis";
import { RedisLeaderboard } from "./redis-leaderboard.js";
import { generateEfforts } from "./seed.js";
import { measure, printResults } from "./benchmark.js";
import { LeaderboardEntry } from "./types.js";
async function getSegmentView(
redis: Redis,
lbKey: string,
athleteId: string
): Promise<{
top10: LeaderboardEntry[];
myEntry: LeaderboardEntry | null;
aroundMe: LeaderboardEntry[];
totalAthletes: number;
percentile: number;
}> {
const pipeline = redis.pipeline();
pipeline.zrange(lbKey, 0, 9, "WITHSCORES"); // top 10
pipeline.zrank(lbKey, athleteId); // my rank
pipeline.zscore(lbKey, athleteId); // my score
pipeline.zcard(lbKey); // total count
const results = await pipeline.exec();
if (!results) throw new Error("Pipeline failed");
What does this pipeline do?
Four Redis commands are queued onto a single pipeline before any of them execute. ZRANGE fetches the 10 lowest-scoring members (fastest times). ZRANK and ZSCORE grab the queried athlete's position and time. ZCARD returns the total number of athletes in the sorted set.
Calling pipeline.exec() sends all four commands in one network round-trip. The result array lines up in the same order: results[0] is the top 10, results[1] is the rank, and so on.
- Continue building getSegmentView by adding the result-parsing logic directly below the last line. Copy this code:
// Parse top 10
const top10Raw = results[0][1] as string[];
const top10: LeaderboardEntry[] = [];
for (let i = 0; i < top10Raw.length; i += 2) {
top10.push({
athleteId: top10Raw[i],
elapsedTimeSeconds: parseFloat(top10Raw[i + 1]),
rank: top10.length + 1,
});
}
// Parse my rank
const myRank = results[1][1] as number | null;
const myScore = results[2][1] as string | null;
const totalAthletes = results[3][1] as number;
let myEntry: LeaderboardEntry | null = null;
let percentile = 0;
if (myRank !== null && myScore !== null) {
myEntry = {
athleteId,
elapsedTimeSeconds: parseFloat(myScore),
rank: myRank + 1,
};
percentile = ((totalAthletes - myRank) / totalAthletes) * 100;
}
What does this code do?
ZRANGE with WITHSCORES returns a flat array alternating between member and score: [athleteId, score, athleteId, score, ...]. The loop steps by 2 to pair them into LeaderboardEntry objects.
The percentile formula ((totalAthletes - myRank) / totalAthletes) * 100 tells the athlete what percentage of the field they beat. This is the same number Strava shows as "You're faster than X% of athletes."
- Finish the getSegmentView function by adding the around-me query, the return statement, and the formatTime helper. Copy this code directly below the previous block:
// Fetch around-me window (separate call since we need myRank first)
let aroundMe: LeaderboardEntry[] = [];
if (myRank !== null) {
const start = Math.max(0, myRank - 5);
const end = myRank + 5;
const aroundRaw = await redis.zrange(lbKey, start, end, "WITHSCORES");
for (let i = 0; i < aroundRaw.length; i += 2) {
aroundMe.push({
athleteId: aroundRaw[i],
elapsedTimeSeconds: parseFloat(aroundRaw[i + 1]),
rank: start + aroundMe.length + 1,
});
}
}
return { top10, myEntry, aroundMe, totalAthletes, percentile };
}
function formatTime(seconds: number): string {
const min = Math.floor(seconds / 60);
const sec = (seconds % 60).toFixed(1);
return `${min}:${sec.padStart(4, "0")}`;
}
Why is the around-me window a separate call?
The first pipeline fetches the athlete's rank, but you need to know the rank before you can calculate the window boundaries. The around-me ZRANGE uses the athlete's position to slice 5 athletes above and 5 below. This mirrors what Strava displays when you view a segment: your position with the athletes immediately above and below you.
Add percentile calculation and display
The query function is complete. Now you need a main function that seeds 500K athletes, calls getSegmentView, and formats the output into a readable leaderboard with the percentile front and center.
- Add the first half of the main function below formatTime. This seeds the leaderboard and prints the top 10 by copying the following code:
async function main() {
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
const segmentName = "highland-climb";
const lb = new RedisLeaderboard(redis, segmentName);
const lbKey = `segment:${segmentName}:lb:all-time`;
await lb.cleanup();
console.log("Seeding 500,000 athletes...");
const efforts = generateEfforts(500_000, segmentName);
await lb.seed(efforts);
const targetId = "athlete-250000";
const view = await getSegmentView(redis, lbKey, targetId);
// Display top 10
console.log(`\n Highland Climb Segment Leaderboard`);
console.log(` ${view.totalAthletes.toLocaleString()} athletes\n`);
console.log(` ${"Rank".padEnd(8)} ${"Athlete".padEnd(20)} ${"Time".padEnd(10)}`);
console.log(` ${"-".repeat(38)}`);
for (const entry of view.top10) {
console.log(
` ${("#" + entry.rank).padEnd(8)} ${entry.athleteId.padEnd(20)} ${formatTime(entry.elapsedTimeSeconds)}`
);
}
- Complete the main function by adding the percentile display, the around-me view, cleanup, and the main() call. Copy this code directly below the previous block:
// Display my position
if (view.myEntry) {
console.log(`\n --- Your Position ---`);
console.log(
` Rank #${view.myEntry.rank} of ${view.totalAthletes.toLocaleString()} | Time: ${formatTime(view.myEntry.elapsedTimeSeconds)} | Top ${view.percentile.toFixed(1)}%`
);
}
// Display around-me
console.log(`\n --- Athletes Near You ---`);
for (const entry of view.aroundMe) {
const marker = entry.athleteId === targetId ? " <-- YOU" : "";
console.log(
` ${("#" + entry.rank).padEnd(8)} ${entry.athleteId.padEnd(20)} ${formatTime(entry.elapsedTimeSeconds)}${marker}`
);
}
await lb.cleanup();
await redis.quit();
}
main();
What does the display code do?
- The "Your Position" block shows the athlete's 1-indexed rank, their formatted time, and their percentile. A rank of 250,000 out of 500,000 means roughly the top 50%.
- The "Athletes Near You" block prints the surrounding window with a <-- YOU marker next to the queried athlete so you can spot your position at a glance.
Before you run this, think about what you expect to see. The target athlete is athlete-250000 out of 500K athletes with random times between 180 and 600 seconds. Where do you think they will land?
- Save src/strava-query.ts.
- Run the segment view by running this command:
npx tsx src/strava-query.ts
You should see a formatted leaderboard with the top 10 fastest athletes, your athlete's rank and percentile under "Your Position", and the 5 athletes above and below under "Athletes Near You."
Not seeing the leaderboard output?
- Check that your Redis container is still running. Run docker ps and confirm you see strava-redis in the list. If not, restart it with docker start strava-redis.
- If you see a type error about results[0][1], make sure the code chunks are pasted in order inside a single getSegmentView function. Each chunk continues the same function body.
- Confirm that main() appears as the very last line of the file, outside of any function.
Help me debug my strava-query.ts output
Run the final benchmark
The segment view is working, but you do not know how fast it is yet. The pipelined query sends 4 commands in one round-trip, plus a second call for the around-me window. Time to measure whether that composition adds meaningful overhead compared to a single getRank call.
- In src/strava-query.ts, scroll to the bottom of the main function.
- Find these two cleanup lines:
await lb.cleanup();
await redis.quit();
- Add the following benchmark code directly above those two lines:
// Benchmark the composite query
console.log("\nBenchmarking composite segment view at 500K...");
const benchResult = await measure(
"Composite view (500K)",
async () => {
await getSegmentView(redis, lbKey, targetId);
},
100
);
printResults([benchResult]);
What does this benchmark measure?
This runs the entire composite query 100 times against the 500K-athlete leaderboard and reports the median, p95, and p99 timings. Because the query pipelines most commands, you should see that fetching an entire segment view (top-10 + rank + neighborhood + percentile) takes only slightly longer than a single getRank call.
Before you run this, think back to your Redis getRank benchmark from Step 3. The composite view does more work (4 pipelined commands plus an around-me call). Do you think it will take noticeably longer, or will pipelining keep it close to the single-command time?
- Save src/strava-query.ts.
- Run the final benchmark by running this command:
npx tsx src/strava-query.ts
You should see the same formatted leaderboard as before, followed by a benchmark table showing the composite view timing at 500K scale. The pipelined approach keeps most of the work in one round-trip, so the composite query is only marginally slower than a standalone getRank.
Seeing a syntax error after adding the benchmark?
- Make sure the benchmark code sits above the await lb.cleanup() line, not below it or outside the main function's closing brace.
- Check that you have not accidentally duplicated the await lb.cleanup() and await redis.quit() lines. They should appear exactly once, after the benchmark.
Help me fix my benchmark code placement
Why pipelining instead of individual calls?
Each individual Redis command is fast (sub-millisecond at 500K), but each one requires a network round-trip. Four separate calls means four round-trips. A pipeline batches those into one trip. The savings matter at scale: Strava serves millions of segment views per day, and every eliminated round-trip reduces latency and server load.
✔️ Awesome, I've got everything!
Double check that you have saved src/strava-query.ts and that the benchmark table printed after the leaderboard view.
ⓧ I'd like to double check the full code
Your src/strava-query.ts file should contain these sections in order. Scroll up and confirm each one is present:
- Five import statements at the top (ioredis, redis-leaderboard, seed, benchmark, types).
- The getSegmentView function containing: pipeline setup with 4 commands, top-10 parsing, my-rank and percentile parsing, around-me window, and a return statement.
- The formatTime helper function converting seconds to M:SS.S format.
- The main function containing: seed (500K athletes), getSegmentView call, top-10 display, "Your Position" with percentile, "Athletes Near You" display, benchmark section, cleanup, and redis.quit().
- A standalone main() call as the very last line.
Secret mission
Time-Windowed Leaderboards
Strava does not show just one leaderboard per segment. It shows all-time, this year, and this month. Can you extend your engine to manage three sorted sets per segment, set TTLs on the time-scoped windows, and fetch an athlete's rank across all three in a single pipelined call?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Docker container strava-redis running Redis 7.4 on localhost:6379.
- Local project folder with TypeScript source files, node_modules, and package.json.
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment further with Redis sorted set commands.
- Your strava-redis container continues running and uses minimal system resources.
- Your project files stay on your Desktop, ready for you to run any of the benchmark or query scripts again.
Pause - I'll come back to this later
Shut down the running container to free up memory. Your project files stay on disk so you can pick up where you left off.
- Stop the Redis container by running:
docker stop strava-redis
- When you are ready to come back, restart it by running:
docker start strava-redis
- Note that all Redis data is lost when the container stops, since this container has no persistent volume. You will need to re-run your seed scripts after restarting.
Delete - I don't want to use this again
Remove all project resources and start fresh. No persistent data to worry about since all data lives in the container's memory.
- Remove the Redis container by running:
docker rm -f strava-redis
- Delete the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf strava-leaderboard
- On Windows, run:
cd ~\Desktop
rmdir /s /q strava-leaderboard
Nice Work!
Nice Work!
You just built a production-grade leaderboard engine modeled on Strava's segment architecture, ranking half a million athletes and proving with your own benchmark numbers why the right data structure matters more than raw speed.
You've learned how to:
- Built and benchmarked a Strava-style segment leaderboard using Redis sorted sets, proving with your own timing data at 10K, 100K, and 500K athletes that the skip list's O(log N) rank computation beats a naive array's O(N) linear scan.
- Implemented Strava's four core leaderboard query patterns: top-N, my-rank, around-me, and personal-best update with ZADD LT for best-time-wins semantics.
- Composed a complete segment view using Redis pipelining to fetch top-10, rank, neighborhood, and percentile in a single network round-trip against 500K athletes.
- Secret Mission: Built time-windowed leaderboards (all-time, this-year, this-month) with TTL-based expiration and cross-window pipelined queries.
Ready to quiz yourself?