Build1 publisher3 min readPublished
SketchProxy trades the Redis round trip for 65,536 buckets that never resize
A Go reverse proxy keeps its rate-limit counters in sketch tables allocated once at startup, so a million rotating IPs cost the same memory as a hundred requests. Its author concedes the flood still gets forwarded.
The Engineer · Build desk

What happened
- A dev.to post describes SketchProxy, an edge reverse proxy built on Go's net/http and httputil.ReverseProxy that keeps its rate-limit state in the proxy process.
- Its sketch tables are allocated once at startup, for example 65,536 buckets, and never resize, so the footprint is identical at 100 requests and at 10 million distinct IP addresses.
- In the post's account of the Redis pattern, 500,000 ephemeral IPs in a few minutes push the cluster to maxmemory, where LRU evicts application cache or writes are rejected and the limiter fails open.
- The post puts the cost of querying a remote cache cluster on every HTTP request at 2 to 10 milliseconds of added p99 latency.
- It also states that a first-seen key has an observed frequency of 1, so under a pure IP-rotation attack every rate limiter forwards the traffic regardless of how it stores counters.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Per-instance tables with no synchronization mean ten proxies behind round-robin routing enforce the configured threshold ten times over. A fleet-wide limit takes coordination the design does not include.
- decision An operator choosing this is choosing which failure to own under abuse: a bounded table that loses precision as it fills, or a counter store that stays precise until it runs out of memory and evicts whatever else it holds.
- capability With stages that short-circuit before the upstream is touched, traffic the proxy rejects consumes no backend capacity, so backend sizing no longer has to absorb the flood the limiter is meant to catch.
Under the Redis pattern the proxy builds a sliding-window key such as `rate:limit:<ip>` and asks the cluster before it decides anything, an approach the post attributes to teams including Stripe, GitHub and Figma [4]. Under SketchProxy the same decision happens inside the process, against a bucket table owned by that instance alone, unsynchronized [2]. Frequency is computed at read time as `count >> age` in a single 64-bit word [12], with compare-and-swap state transitions the author clocks under 35 nanoseconds across cores [13]. The low end of the round-trip figure, 2 milliseconds, is about 57,000 times that [5]. The two numbers do not come from one harness: one is a network hop, the other a word update in local memory, and the post does not publish a measured run of the two designs under the same load.
The memory claim is checkable. A table of 65,536 buckets holding one 64-bit word each is 524,288 bytes, about 512 KiB, allocated at startup and never grown [1]. The post's attack diagram puts a sharded map or Redis limiter at a million stored keys and a memory ladder of 250 MB, then 1 GB, then 2 GB, ending in an OOM kill [11]. Divide it out and each tracked key costs 250 bytes [2]. Keeping the counters local does not by itself fix this: a `map[string]*counter` allocates an entry per new IP until GC thrashing turns into an OOM crash [10].
Fixed memory is paid for in precision. A million distinct keys over 65,536 buckets averages about 15 keys per bucket [3], and the eviction rule described in Part 2 chips bits off whatever is already sitting in a bucket, so keys that share one compete for it [13]. A rotating botnet cannot exhaust the table, but it does churn buckets that legitimate clients hash into as well. For the memory figure to transfer to your edge, your real client cardinality has to sit far enough below the bucket count that the limiter still fires on the clients you meant to limit.
The rotation itself goes through, and the post is direct about why: blocking never-before-seen keys would block every legitimate new user visiting the site [9]. "The attacker doesn't need to take down the application; they turn the rate limiter against itself," the post says [17]. The case against the cache hop is an availability case as much as a latency one. Under heavy concurrency the post puts connection pool exhaustion and Redis's single-threaded event loop ahead of the backend as the first thing to saturate [7].
Where I think this trades well is a single edge node that only ever enforced a per-node limit anyway. The response side changes too: in place of the binary the post names, either 200 OK or an immediate 429 drop, SketchProxy defines a two-tier volume band whose first parameter is `RateLimit.Threshold` [15].
What to watch
- A side-by-side benchmark of SketchProxy and a Redis limiter under the same million-IP flood.
- Documentation for the second tier of the volume band and the defaults shipped with it.
- Whether the bucket count gets sizing guidance tied to expected client cardinality.