Engineering in practice

FastAPI + Redis — cache, rate limit and lock together

13 October 2026 · 8 min read

Unifying Three Everyday API Primitives

In almost every scalable FastAPI deployment, three architectural requirements inevitably arise:

  1. Caching expensive database aggregations to reduce backend latency.
  2. Rate limiting endpoints to protect services against malicious traffic bursts and scrapers.
  3. Distributed locking to prevent race conditions during critical writes across multiple container instances.

While many teams install three bulky third-party libraries—each wrapping Redis with its own connection pool and idiosyncrasies—all three concerns can be implemented elegantly using pure Redis primitives and single-command atomicity.


1. Safe Invalidation-Aware TTL Caching

A proper caching layer requires three strict rules:

  • Mandatory TTL: Never permit cache insertions without an expiration interval. Forgotten TTLs inevitably trigger out-of-memory (OOM) evictions.
  • Atomic Cache Miss Handling: Prevent cache stampedes by combining lookup and creation within an asynchronous helper.
  • Namespace-Based Purging: Scope keys with clear domain prefixes (cache:v1:users:123) to allow instant invalidation when schemas evolve.
import json
from collections.abc import Callable, Awaitable

class RedisCache:
    def __init__(self, redis):
        self.redis = redis

    async def get_or_set(
        self,
        key: str,
        ttl_seconds: int,
        factory: Callable[[], Awaitable[dict]],
    ) -> dict:
        cached = await self.redis.get(key)
        if cached:
            return json.loads(cached)

        value = await factory()
        await self.redis.set(key, json.dumps(value), ex=ttl_seconds)
        return value

2. Sliding-Window Rate Limiting via Lua

Simple fixed-window counters allow double the permitted traffic across interval boundaries (e.g., 100 requests at 11:59:59 and 100 requests at 12:00:01).

To enforce a continuous sliding window without race conditions, execute the check atomically inside Redis using a Lua script operating on sorted sets (ZSET):

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

-- 1. Remove timestamps outside the active sliding window
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)

-- 2. Count requests remaining in window
local current_count = redis.call('ZCARD', key)

if current_count < limit then
    -- 3. Record current request timestamp
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, window)
    return 1 -- Allowed
end

return 0 -- Rejected

Because Lua scripts execute atomically within Redis’s single-threaded event loop, parallel requests across multiple FastAPI workers cannot violate the limit.


3. Distributed Lock with Ownership Verification

When synchronizing critical sections across horizontally scaled API replicas (e.g., preventing duplicate stripe charge processing or report generation), distributed locks are essential.

The crucial requirement is safe lock release: Worker A must never release a lock owned by Worker B if Worker A took longer than the lock’s TTL:

import secrets
import asyncio

class RedisDistributedLock:
    def __init__(self, redis, resource_name: str, ttl_seconds: int = 30):
        self.redis = redis
        self.key = f"lock:{resource_name}"
        self.ttl_seconds = ttl_seconds
        self.token = secrets.token_hex(16)

    async def __aenter__(self):
        acquired = await self.redis.set(
            self.key, self.token, nx=True, ex=self.ttl_seconds
        )
        if not acquired:
            raise ResourceLockedError(f"Resource {self.key} is currently locked.")
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        # Compare and Delete via Lua: only release if our token still matches
        release_script = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        end
        return 0
        """
        await self.redis.eval(release_script, 1, self.key, self.token)

By relying directly on atomic Redis commands, we achieve high reliability with zero unnecessary framework bloat.