Redirects at Scale: From Sequential Rules to O(log n) Lookups
Redirects are straightforward when you have a few dozen of them, but millions change the problem entirely. Latency and cost stop being negligible and become core systems concerns that demand a dedicated architecture rather than a general-purpose solution.
Vercel previously handled redirects through routing rules and middleware. Routing rules work as an ordered list evaluated in sequence, with a ceiling of about 2,000 complex rules supporting wildcards. Each rule can involve regex matching, so a single request might trigger many expensive evaluations. That approach works for thousands of rules, but per-request work grows linearly as counts increase. Middleware offers more flexibility but runs extra code on every request, adding unavoidable latency. To handle millions of redirects with low latency, the request path needed near-constant or logarithmic time complexity instead of linear.
What Mattered and What Didn't
The design goals for the new redirect path were specific. It had to support millions of static redirects per project, add no latency for projects that don't configure redirects, and provide a fast "no redirect" path since the majority of requests will not match anything. Process memory usage needed to stay low, shifting the burden to external storage and caching layers. The engineering approach favored simplicity and debuggability over premature optimization, with iterative evolution rather than aiming for perfection on the first attempt.
The initial design combined redirect data with Bloom filters in a single file using JSONL format, since redirect data was already JSON and existing Bloom filters could export to JSON. A Bloom filter is a probabilistic data structure that answers "definitely not in the set" or "maybe in the set"—it allows false positives but never false negatives. A small, cached Bloom filter in front of the lookup could skip the redirect check entirely for requests that don't match, keeping the common negative case extremely cheap. Only on a positive match would the JSON file be parsed.
Napkin Math Ends the Simplicity Run
The first design was simple, but the math said it wouldn't scale. A million redirects could easily produce a file in the hundreds of megabytes, and fetching and parsing a file that large would blow latency and memory budgets. Loading the entire dataset at once was not viable.
The fix was sharding. Instead of one massive JSONL file, the redirect path is hashed to distribute entries across many small shards. Each request loads only a small slice of the data for its specific shard, moving the burden from process memory to external storage and the file system cache. The Bloom filter still sits in front and short-circuits the lookup for most traffic. When a request does pass the Bloom filter, only a single small shard is fetched and parsed—never the whole set.
Shard Anatomy
Each shard contains three parts: a header line encoding the Bloom filter properties, the base64-encoded Bloom filter, and a JSON object of redirects keyed by source path:
{"version":"bulk-redirects","bloom":{"n":3,"p":1e-7,"m":102,"k":23,"s":0}}
"Mec7FxGVcJ0fHdj8HA=="
{"/old-path":{"destination":"/new-path", ...},"/another-old-path":{"destination":"/another-new-path", ...}, ...}
At build time, all shards and their Bloom filters are generated and uploaded to external storage. At runtime, the server only needs to know which dataset and shard count apply to a given project or deployment when it receives a request. The request-time lookup path works as follows:
- Check whether the project or deployment has bulk redirects configured. If not, skip everything and proceed as usual.
- Compute the redirect key from the incoming request and hash it to determine the shard.
- Retrieve the shard from cache or origin, then check the Bloom filter.
- If the key is not present in the Bloom filter, skip parsing the JSON body entirely.
- If the key is maybe present, load the shard's JSON body and look up the exact redirect in that object.
This design carried several practical advantages. Bloom filters are fast and tunable to a very low false-positive rate, making negative lookups cheap. Shards are human-readable JSONL files, so dumping a shard reveals exactly what it contains when something fails. JSON parsing and Bloom filters are both well-understood, which kept implementation risk low and allowed the feature to ship quickly with real-world data.
Parsing Becomes the Bottleneck
Dogfooding confirmed the suspicion that JSON parsing would be the weakest link. When the Bloom filter signaled a redirect might exist, parsing the full JSON body of the relevant shard took considerable time. Under high CPU load, latency spiked massively because JSON parsing is CPU-intensive and competes for resources with everything else on the node.
The Shard Size Trade-off
Shrinking shards would reduce parsing time, but smaller shards increase cardinality—more shards to manage—and drive up cache miss rates. Large shards meant high CPU overhead from parsing; small shards meant more I/O latency from cache misses. The solution required a data format that could retrieve a single value without parsing the entire shard.
Binary Search Over Sorted Keys
The replacement abandoned JSON blobs in favor of binary search keyed by redirect path. Each shard stores its redirect keys in sorted order, enabling logarithmic-time search over those keys. Once a key is found, only the JSON for that specific redirect needs parsing. Lookup cost no longer scales with the total amount of data in the shard, so shards can stay large enough for good cache hit rates without paying the full JSON parsing penalty.
{"version":"bulk-redirects","bloom":{"n":3,"p":1e-7,"m":102,"k":23,"s":0}}
"Mec7FxGVcJ0fHdj8HA=="
"/old-path"
{"destination":"/new-path", ...}
"/another-old-path"
{"destination":"/another-new-path", ...}
Spikes Gone, Latency Down
With JSON parsing out of the hot path for positive lookups, requests for redirects that actually exist became faster and more predictable. The elimination of the latency spikes under high CPU load was the most visible improvement. When full-shard JSON parsing competed for CPU time with everything else on the node, redirect lookups suffered under contention. With binary search, per-request CPU cost dropped low enough that resource competition stopped being a factor.
Designing for the Common Case
Routing rules were the wrong tool for large redirect sets. The dedicated path for bulk redirects combines three elements: shard the redirect data so each piece stays small, use Bloom filters to keep the common "no redirect" case cheap, and store redirects in a layout that supports binary search over keys.
The development cycle reinforced a recurring principle: avoid premature optimization. Starting with a simple, debuggable implementation and instrumenting it let production data dictate where complexity was actually needed. The first design failed gracefully under real traffic patterns, and binary search addressed the specific bottleneck that data identified—not a hypothetical one.
Bulk redirects are available for Pro and Enterprise customers, configurable via project configuration, the dashboard, API, or CLI. The current limit is 1 million redirects per project. Use cases include large-scale migrations, fixing broken links, and handling expired pages.



