Build a Rate Limiter and Leaderboard with Redis
A rate limiter and a leaderboard both need frequent, atomic updates. They can be built in a relational database, but Redis has primitives designed for exactly these access patterns: counters with expiration and sorted sets.
The implementation details still matter. A counter and its expiration must be created atomically, and an in-memory data store needs an explicit durability and failure policy.
Who this is for: TypeScript developers evaluating Redis for request-path counters and frequently updated rankings.
Outcome: One runnable script with an atomic fixed-window rate limiter and a sorted-set leaderboard.
Time: About 15 minutes, plus the first Redis binary download.
Prerequisites: Node.js 20 or newer and pnpm. No Docker or Cloud account is required.
Start Redis locally
Install the Layerbase CLI, then create a Redis instance:
npm i -g layerbase
lbase create redis-features -e redis --start
lbase url redis-featuresThe last command prints the actual connection string:
redis://127.0.0.1:6379/0If that port is occupied, use the URL printed by the CLI.
Create the TypeScript project:
mkdir redis-features
cd redis-features
pnpm init
pnpm add redis
pnpm add -D tsx typescriptBuild both features
Create redis-features.ts:
import { createClient } from 'redis'
function createRedisClient() {
const rawUrl = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'
const parsed = new URL(rawUrl)
if (parsed.protocol !== 'rediss:') {
return createClient({ url: rawUrl })
}
return createClient({
username: decodeURIComponent(parsed.username || 'default'),
password: decodeURIComponent(parsed.password),
socket: {
host: parsed.hostname,
port: Number(parsed.port || 6379),
tls: true,
servername: parsed.hostname,
},
})
}
const client = createRedisClient()
client.on('error', (error) => console.error('Redis error:', error))
await client.connect()
const RATE_KEY = 'tutorial:rate:user-42'
const LEADERBOARD = 'tutorial:leaderboard'
await client.del(RATE_KEY)
await client.del(LEADERBOARD)
const fixedWindowScript = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count
`
async function checkRateLimit(
key: string,
limit: number,
windowSeconds: number,
): Promise<{ allowed: boolean; remaining: number }> {
const reply = await client.eval(fixedWindowScript, {
keys: [key],
arguments: [String(windowSeconds)],
})
const count = Number(reply)
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
}
}
console.log('Rate limiter:')
for (let request = 1; request <= 7; request += 1) {
const result = await checkRateLimit(RATE_KEY, 5, 60)
const status = result.allowed ? 'ALLOWED' : 'BLOCKED'
console.log(
` Request ${request}: ${status} (${result.remaining} remaining)`,
)
}
await client.zAdd(LEADERBOARD, [
{ value: 'alice', score: 2500 },
{ value: 'bob', score: 1800 },
{ value: 'charlie', score: 3200 },
{ value: 'grace', score: 3400 },
{ value: 'eve', score: 2700 },
])
await client.zIncrBy(LEADERBOARD, 500, 'alice')
await client.zIncrBy(LEADERBOARD, 1100, 'bob')
const leaders = await client.zRangeWithScores(
LEADERBOARD,
0,
4,
{ REV: true },
)
console.log('\nLeaderboard:')
leaders.forEach((entry, index) => {
console.log(` #${index + 1} ${entry.value}: ${entry.score}`)
})
await client.quit()The Lua script makes INCR and the first EXPIRE one atomic operation. A process failure cannot leave the counter without a TTL between those two commands.
Run the script with the connection string returned by the CLI:
REDIS_URL="$(lbase url redis-features)" pnpm tsx redis-features.tsExpected output:
Rate limiter:
Request 1: ALLOWED (4 remaining)
Request 2: ALLOWED (3 remaining)
Request 3: ALLOWED (2 remaining)
Request 4: ALLOWED (1 remaining)
Request 5: ALLOWED (0 remaining)
Request 6: BLOCKED (0 remaining)
Request 7: BLOCKED (0 remaining)
Leaderboard:
#1 grace: 3400
#2 charlie: 3200
#3 alice: 3000
#4 bob: 2900
#5 eve: 2700Both key names start with tutorial: and are deleted at the beginning of the run. The script does not flush the database or touch unrelated keys.
Know what this limiter guarantees
This is a fixed window that starts with the first request. It is not a sliding-window limiter. A client can send five requests just before the key expires and another five immediately after it expires.
That boundary burst is acceptable for many product limits and abuse controls. For stricter traffic shaping, use a token bucket or a sliding log built with a sorted set and an atomic script.
Decide what happens when Redis is unavailable:
- Fail open if serving the request is safer than blocking a legitimate user.
- Fail closed if exceeding the limit creates a security or financial risk.
- Use a small local fallback if temporary inaccuracy is acceptable.
The correct choice depends on what the limit protects. Hiding the failure behind a default true or false is not a reliability strategy.
Understand leaderboard durability
Sorted sets keep members ordered by score, which makes rank and top-N reads straightforward. They do not make Redis a permanent system of record by themselves.
Review the server's persistence, replication, and eviction configuration before storing irreplaceable scores. A common design writes durable game events or final scores to a primary database and uses Redis as the fast ranking view. If the Redis data is lost, the leaderboard can be rebuilt.
Redis lists can also implement a simple destructive work queue, but LPUSH plus BRPOP does not provide acknowledgments. Use Redis Streams or a queue system with explicit retry and dead-letter behavior when jobs must survive worker failure.
Move the proven workflow to Layerbase Cloud
After the local behavior is correct, create Redis on Layerbase Cloud and copy its rediss:// connection string from Quick Connect:
REDIS_URL="rediss://default:password@your-host.cloud.layerbase.dev:6379" \
pnpm tsx redis-features.tsThe script parses the TLS URL and sends the database hostname as the TLS server name. That is required because Redis traffic on Layerbase Cloud is routed by hostname over the shared TLS port.
As verified on July 23, 2026, Redis is available on the Free plan. Free includes up to two databases and sleeps databases after 60 minutes of inactivity. It can wake on connect, which is useful for evaluation and low-traffic projects. A rate limiter in a latency-sensitive request path should use an always-on paid configuration instead of depending on a sleeping database. Check current pricing before making a purchasing decision.
Keep the password in server-side environment variables. Do not expose a write-capable Redis connection to browser code.
When Redis is the wrong fit
Redis is useful when the access pattern matches its data structures and the team has a clear recovery policy. It is usually the wrong first choice when:
- An indexed query in the primary database already meets the latency target.
- The data must survive without a separate durable source or tested backup.
- The team does not want to operate another stateful service.
- The workload needs relational joins or ad-hoc reporting.
If licensing, migration, or long-term project governance is part of the decision, read Redis versus Valkey before choosing. The next tutorial, building a cache and session store with Valkey, covers a different outcome without repeating this rate limiter.
The Layerbase CLI is the fastest local path. Layerbase Desktop provides the same workflow in a GUI on macOS, Windows, and Linux. Move to Cloud after the failure and durability policies are explicit.
Keep reading
- Add Redis or Valkey caching to a Lovable appLovable ships a browser SPA with no server, so a cache lives in a Supabase Edge Function, not your React code. Here is how to add a Redis-compatible Valkey the right way.
- Run Redis on Windows (No Docker, No WSL)Redis OSS ships no native Windows binaries. Run Redis natively on Windows without Docker or WSL using custom binaries from Layerbase Desktop or SpinDB.
- Migrating from Vercel KV to LayerbaseVercel KV was sunset and its stores moved to Upstash Redis, so the move is a data copy and a one-line client swap. Paste the rediss:// string behind your project Storage tab and copy every key, type, and TTL into flat-priced managed Redis or Valkey.
- Migrating from Upstash to LayerbaseUpstash bills per request, which is great at zero traffic and surprising at scale. Here is how to move to flat-priced managed Valkey on Layerbase: copy every key with one API key, and swap the REST client for a standard Redis driver.