How to Make Autocomplete Feel Instant Across 240 Million Domains
The trick is to borrow time from typing
Press a key in a good search box and the suggestions seem attached to your fingers. In a slower one, the same list exposes every trip to the network: type, wait, redraw. That difference becomes difficult to hide when autocomplete must search roughly 240 million domain names.
A search-shaped question is how to make autocomplete feel instant when the dataset is far too large to keep in every browser. The answer is not a server that responds in literally zero milliseconds. It is a client that starts work before the user expects to see the result, backed by an index designed for predictable lookups.
Measure the moment users notice
The usual latency measurement starts when a request leaves the browser. That is useful for capacity planning, but it misses the experience. With keyboard autocomplete, a better starting point is the moment the user releases the key and expects the list to change.
The web's normal keyboard event order creates a small opportunity. A character key produces keydown when it is pressed, followed by text input events, then keyup when it is released. The request can begin during keydown, while the user is still holding the key. (w3.org)
A 60 Hz display refreshes about every 16.7 milliseconds, but the more valuable budget is the whole interval between two visible decisions. It includes the first key's press duration, the pause between keys, and the second key's press duration:
latency budget = first press + gap between keys + second press
If the API response arrives before the second key is released, the interface can render immediately. The network request still took time. The user experienced none of that time as waiting. This is the meaning behind a p99 latency of 0 milliseconds: in 99 percent of cases, results are ready by the chosen visual boundary, not that the server performed no work.
Prefetch before the next character exists
The browser can cache promises, which are JavaScript objects representing work that may finish later, rather than waiting for completed result arrays. On keydown, it asks for the prefix that includes the new character. On keyup, it displays the cached response if it has arrived.
const cache = new Map;
function prefetch(prefix) {
if (!cache.has(prefix)) {
cache.set(
prefix,
fetch('/autocomplete?q=' + encodeURIComponent(prefix))
.then(response => response.json)
);
}
return cache.get(prefix);
}
search.addEventListener('keydown', event => {
if (event.key.length!== 1) return;
prefetch(search.value + event.key);
});
search.addEventListener('keyup', async event => {
if (event.key.length!== 1) return;
const packet = await prefetch(search.value);
render(packet.results);
});
The response can go further than the current prefix. Alongside results for wi, it can include separate lists for wia, wik, wiz, and every other valid continuation. The next keystroke then has a chance to use data already sitting in memory instead of opening another round trip.
A production input also needs paths for paste, deletion, mobile keyboards, and input method editors, which compose text in stages. The keyboard handler is the acceleration path, not the complete definition of what the input contains.
Split popular names from the long tail
Searching hundreds of millions of strings on every keystroke would be wasteful even on a fast machine. The practical design divides the data into a small, hot head and a much larger tail.
The head comes from Tranco, a one-million-domain popularity ranking assembled from several provider rankings over a rolling period. That makes it useful for putting familiar names first rather than treating every registered domain as equally likely. (tranco-list.eu)
For this portion, an in-memory character trie works well. A trie, also called a prefix tree, stores one character at each branch. The prefixes wi, wik, and wiki share the same path, so looking up a name means walking a handful of pointers instead of scanning a million rows. The top eight suggestions for each prefix can be prepared ahead of time and returned in rank order.
The tail needs a different trade-off. Zone files from ICANN's Centralized Zone Data Service, or CZDS, provide domain listings for participating generic top-level domains such as .com, .net, and .org. CZDS is not a universal directory of every country-code namespace, so a popularity list and other sources are still useful for coverage. (czds.icann.org)
The tail can be sorted, delta-compressed, and divided into fixed-size blocks. Delta compression stores the difference between neighboring names or offsets, which removes repeated information from sorted data. A small in-memory directory points to each block; a lookup binary-searches that directory and then scans a single block of names.
In the reported design, the directory occupies about 27 MB, each block contains 256 names, and roughly 240 million names consume about 2.5 GB on disk. A memory-mapped file lets the operating system load the pages needed for a lookup without forcing the application to copy the entire index into memory. (ruurtjan.com)
The trie lookup grows with the number of typed characters. The tail lookup adds a logarithmic search through the block directory. Calling both paths effectively constant-time is a practical statement about bounded inputs: domain queries are short, the character set is limited, and the block size is fixed. It is not a claim that arbitrary strings and arbitrary datasets have constant cost.
Benchmark the tail, not the average
Autocomplete lives or dies by its slow cases. p50 is the median request, while p99 describes the point below which 99 percent of requests finish. A system can have a pleasant median and still feel broken when one request in a hundred pauses the interface.
The reported stress test generated 720,000 keystroke queries from 60,000 simulated domain names and replayed them at a fixed rate. This is called an open-loop test because requests continue according to the schedule even when earlier responses are slow. Most API-only requests finished within 2 milliseconds, and the nginx reverse proxy plus API reached a 15 millisecond p99 at 1,600 requests per second.
At that point, shaving another millisecond from the index has little value. The dominant cost is the round trip between the browser and the server. A content delivery network, or CDN, can place cached responses closer to users, but dynamic JSON is not cached by default on Cloudflare; an explicit cache rule and suitable time-to-live are needed. That works best for public, repeatable prefix responses, not data personalized to one visitor. (developers.cloudflare.com)
Geography remains stubborn. A single European server can serve nearby users within the typing budget while users across the Atlantic miss it. Multiple regional servers and geographic routing would improve the tail, but they would also turn a small autocomplete feature into a distributed deployment project.
The useful asterisk
The most interesting part of this design is not the trie or the memory-mapped file in isolation. It is the decision to include human behavior in the latency model. Typing already creates a short period during which the user is occupied, so the system uses that period to fetch and prepare the next answer.
That changes the optimization question. Instead of asking how fast a server can respond after the user notices a delay, measure how much work can finish before that delay becomes visible. Once the data structures make the backend predictable and the browser starts early, a huge domain index can feel local—even when the server is hundreds or thousands of miles away.
Comments (0)
No comments yet. Be the first to respond!
Leave a Comment
Your comment will be visible after review.