Skip to content

Build a Rate Limiter and Leaderboard with Redis

4 min readRedisDatabasesCaching

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:

bash
npm i -g layerbase
lbase create redis-features -e redis --start
lbase url redis-features

The last command prints the actual connection string:

text
redis://127.0.0.1:6379/0

If that port is occupied, use the URL printed by the CLI.

Create the TypeScript project:

bash
mkdir redis-features
cd redis-features
pnpm init
pnpm add redis
pnpm add -D tsx typescript

Build both features

Create redis-features.ts:

typescript
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:

bash
REDIS_URL="$(lbase url redis-features)" pnpm tsx redis-features.ts

Expected output:

text
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: 2700

Both 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:

bash
REDIS_URL="rediss://default:password@your-host.cloud.layerbase.dev:6379" \
pnpm tsx redis-features.ts

The 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.