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:
- Caching expensive database aggregations to reduce backend latency.
- Rate limiting endpoints to protect services against malicious traffic bursts and scrapers.
- 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.