The rate-limiting chapter walked through the algorithms in isolation — token bucket, leaky bucket, the two window counters — as though a single process somewhere was quietly enforcing them alone. The far more interesting question is a different one entirely: once a whole fleet of app servers is supposed to be honoring one shared limit together, whose copy of the count is the real one?
Let each app server keep its own bucket in its own memory, and put five of those servers behind a load balancer, and a client meant to be capped at ten reserve attempts a minute can walk away with fifty — ten from each of the five, since none of those buckets has any idea the other four are even running. It's the statelessness problem from chapter two, back in a new disguise: a rate limit is itself a piece of state, and state kept separately on every instance can't add up to one correctly enforced shared limit the moment a second instance enters the picture.
Fanline's answer is to stop letting every app server keep its own count and instead point the whole fleet at one shared, low-latency store — Redis, in this case, picked because it's quick enough to live directly on the critical path of every reserve-seat request and hands you the atomic primitives this problem actually needs:
Customer -> Load Balancer -> whichever App Server picks it up -> Redis (one bucket per client, shared fleet-wide)
|
Order Database (a separate concern entirely)
Now every app server is checking and spending tokens against the same bucket for a given client, no matter which server actually caught that client's request — which restores exactly the correctness the naive per-server version was missing.
Moving the bucket into Redis doesn't automatically finish the job, either. Write the logic naively — fetch the token count, compare it against the limit, then send back the decremented value, as three separate round trips — and there's a gap sitting right in the middle of it. Picture two of Fanline's app servers each handling a different request from the same bot, arriving close enough together that both requests execute their "fetch" step before either one gets to "write." Both see the same lone token sitting there. Both conclude they're allowed to proceed. Both write back a decrement. One token just authorized two reservation attempts, and the scalper's script got exactly the extra shot it was looking for. This is the same check-then-act race that shows up any time two threads read a shared value before either one has committed its own update.
The fix collapses the read, the comparison, and the write into one indivisible step instead of three separate round trips. Redis gives you this natively, either through a single atomic command or a short Lua script that the server runs start to finish without interruption, so nothing else gets a chance to squeeze in partway through:
-- runs atomically, e.g. as a Redis Lua script --
tokens = GET(clientKey)
if tokens is nil:
tokens = bucketCapacity
if tokens < 1:
return DENY
SET(clientKey, tokens - 1, expireIn = refillWindow)
return ALLOW
Every reserve-seat attempt now owes Redis a network round trip before the app server gets to do any real work — typically on the order of a millisecond, which sounds trivial until you notice it's sitting directly inside the most time-sensitive request Fanline serves. Some teams soften this on purpose: let each app server track a rough local estimate and reconcile with the shared store only every so often, accepting that a client might slip a handful of requests past the true limit fleet-wide in exchange for shaving that latency off every request. Fanline's reserve endpoint doesn't take that bargain — a scalper who discovers the limit is only loosely enforced will live inside that gap indefinitely — so paying the extra millisecond for something strict and atomic is worth it here, the same logic that justified paying a coordination cost on the reservation path back in the CAP chapter. Something lower-stakes, like a rough "how many people are looking at this listing right now" counter on the browse page, can happily take the cheaper, approximate route instead.
One more wrinkle worth a mention: clock skew. Any window-based scheme spread across multiple servers can judge a client differently depending on which server's clock that judgment happened to run against, especially for a request landing right on a window's edge. It's yet another argument for one shared, authoritative clock — Redis's own server time, rather than trusting a fleet of app servers to already agree with each other about what time it is.