How a Phone’s Contact Search Finds Matches as You Type

contact search as you type

You type the letter “m” and Mom appears. You add an “a” and the list shrinks. By the third letter, the person you wanted is already on screen and you have not finished typing. It feels like magic, but it is a handful of well-known tricks working together, and every one of them is something you can build yourself in an afternoon. This article takes the search apart piece by piece, with working code, real measurements and everyday pictures.

The magic trick, and why it needs engineering

Search-as-you-type looks simple until you ask what the phone is really being asked to do. Every single keystroke is a new question. By the time you type the fourth letter of a name, the phone has answered four separate searches, and it has to finish each one before your finger lands on the next key. Usability researchers have used a rule of thumb for decades: if a response arrives in about a tenth of a second, people experience it as instant. Anything much slower and the interface starts to feel like it is lagging behind you.

Now picture the other side of the problem. A phone might hold a few hundred contacts, but plenty of people have thousands, once you count work contacts, old numbers and group chats. And each contact is not just a name. It has several numbers, an email, a nickname, a company. The search has to be fast, forgiving, and sensible about which result to put first.

I have built search boxes for products of every size over the years, and I always start new engineers with this exact exercise. It teaches normalisation, data structures, ranking and the limits of “just loop over everything” in one small, familiar problem.

The short version. A contact search cleans up what you type (lowercase, no accents), looks it up in an index built ahead of time instead of scanning every contact, ranks the matches so the most likely person comes first, and can forgive small typos. Apps differ in the details, and the vendors do not publish their exact internals, but these are the standard building blocks, and they are what we will build here.

The pipeline in one picture

Whatever the app, the path from keystroke to list looks about the same.

1 Keystrokeyou type “ani”2 Normaliselowercase, no accents3 Look upin a prebuilt index4 Rankbest match first5 Showtop results on screenThe index in step 3 is built once, ahead of time, and updated only when contacts change.
Five steps run on every keystroke. The expensive work is done ahead of time, when the index is built.

Let us walk through the stages in order, starting with the most naive version that works, because the way it fails shows you why each later trick exists.

Step 1: clean up what people type (normalisation)

Before any matching happens, both the typed text and the stored names need to be put into the same shape. People type “jose” for José, they type in lower case, and they put dots and dashes into names such as “Anne-Marie” or “O’Brien”. If you compare raw text, none of those match.

So the first stage lowercases everything, strips accents, replaces punctuation with spaces and splits the name into words. That is called normalisation, and it is the unglamorous step that makes the rest work. Skipping it is the number one reason homemade search feels “broken.”

Below is a first version of the whole search in about twenty lines. It normalises, then checks every contact. A contact matches when every word you typed is the beginning of some word in the name.

import unicodedata, re

def normalise(text):
    """Lowercase, strip accents, turn punctuation into spaces."""
    text = unicodedata.normalize("NFKD", text)
    text = "".join(c for c in text if not unicodedata.combining(c))
    return re.sub(r"[^a-z0-9 ]+", " ", text.lower()).split()   # list of words

def linear_search(contacts, query):
    """Slowest idea that works: look at every contact on every keystroke."""
    q = normalise(query)
    hits = []
    for cid, words in contacts:                  # contacts = [(id, [words...]), ...]
        # every typed word must be the start of some word in the name
        if all(any(w.startswith(t) for w in words) for t in q):
            hits.append(cid)
    return hits

This already behaves like a real contact search. Type “sh” and you find both Onam Sharma (the surname starts with sh) and Shalini Rao (the first name does). Type “on sh” and only Onam Sharma remains, because each typed word must begin some word in the name. Matching at the start of any word is what makes phone searches feel natural: you can type the first or the last name, in any order.

Real-world picture: a library catalogue. You do not need the exact title. You type a few starting letters of any word and the system narrows the shelf for you.

Step 2: watch the results shrink as you type

Here is something satisfying to see. I generated 5,000 made-up contacts (random first and last names) and ran the search above while “typing” the name anit, one letter at a time. My test names lean heavily towards names beginning with A, so the early numbers are larger than a real address book would give, but the shape is what matters.

nothing typed5,000a1,188an568ani186anit90Matching contacts out of 5,000 (made-up names that lean towards A, so early counts run high).
Each new letter cuts the candidate list sharply. Out of 5,000 contacts, four letters leave 90.

This shrinking is the whole reason search-as-you-type works so well. The first letter does the least work and returns the longest list, which the phone only needs to display the top of. Every next letter cuts the list further, and by the fourth or fifth letter you are usually looking at a handful of names.

It also hides a second, very practical trick. Every result for “ani” is guaranteed to also be a result for “an”, because anything starting with “ani” also starts with “an”. So instead of searching all contacts again, you can search only the list you already have. I counted the name checks for typing anit across 10,000 contacts: scanning everything on every keystroke took 40,000 checks, while reusing the previous results took 13,852. That is about two-thirds fewer, for a change of a few lines of code.

The catch. This only works while the user keeps typing forward. The moment someone presses backspace, the saved list is too small, and you must go back to an earlier result or start over. Good apps keep a short history of the lists for exactly that reason.

Step 3: stop scanning, start looking up (the index)

The scan-and-reuse approach is fine for a thousand contacts. But it still touches every candidate on the first keystroke, and the cost grows with the size of the list. Real search systems avoid scanning entirely. They do the hard work once, ahead of time, and build an index, which is a structure designed to answer one specific question quickly. For “which words start with these letters?” the classic answer is a trie.

What is a trie?

A trie (said “try”) is a tree where each step down spells one more letter. All words that share a start share the same path. To find everything starting with “ani”, you walk three steps down and you are standing on the answer: every contact reachable from that spot begins with those letters. You never look at the other thousands of contacts at all.

•amniiktltiatAmitAnilAnitaAnkitWalk down the letters you typed. Everything below that spot is a match.
A small trie for the words Amit, Anil, Anita and Ankit. They share the first letter, and Anil, Anita and Ankit share the first two.

The cost of a lookup depends on the length of what you typed, not on how many contacts you own. A person with 100 contacts and a person with 100,000 both pay the same few steps for “ani”. That is the property that makes it scale.

Here is a compact version. In this one, every node remembers the set of contacts passing through it, so reading the answer is instant:

class Trie:
    """Every node remembers which contacts pass through it."""
    def __init__(self):
        self.root = {}
    def add(self, word, cid):
        node = self.root
        for ch in word:
            node = node.setdefault(ch, {})
            node.setdefault("#", set()).add(cid)  # '#' holds contact ids
    def prefix(self, text):
        node = self.root
        for ch in text:
            node = node.get(ch)
            if node is None:
                return set()
        return node.get("#", set())

def build(contacts):
    t = Trie()
    for cid, words in contacts:
        for w in words:
            t.add(w, cid)
    return t

def trie_search(t, query_words):
    sets = [t.prefix(w) for w in query_words]
    return set.intersection(*sets) if sets else set()

The trade-off is memory. The trie stores extra information so lookups are fast, which is the oldest bargain in computing: spend space and preparation time to buy speed.

A simpler cousin: the sorted list and binary search

If a trie sounds like more machinery than you want, there is a leaner way to get the same effect. Keep every word in one alphabetically sorted list. All words starting with “ani” sit together in one block, because they sort together. Use binary search to find where that block begins and ends, which takes only about seventeen steps even for a hundred thousand words. If you read our article on heaps and sorted arrays, this is the same “sorted array” idea put to a different job. It uses less memory than a trie and is easier to write, and it is a perfectly good choice.

The numbers: how much faster is an index?

I built all three versions (scan, sorted list with binary search, and trie), checked that they return identical results on 500 random queries, and then timed them. The test is typing the four letters anit, taking the best of five runs, with different address book sizes. These figures come from Python on a cloud machine, so a real phone running compiled code will be quicker in absolute terms. What matters is the ratio.

ContactsScan everythingSorted list + binary searchTrieTrie vs scan
1,0003.13 ms0.025 ms0.011 ms275x faster
10,00032.87 ms0.28 ms0.089 ms368x faster
100,000345.59 ms8.18 ms1.31 ms263x faster
Scan everything345.6 msSorted list + bisect8.2 msTrie1.3 msTyping “anit” with 100,000 contacts. Bar length uses a log scale: each 1/3 of the width is 10 times longer.
Time to type four letters with 100,000 contacts. The scan is hundreds of times slower than the trie.

Look at the first row of the table. With 1,000 contacts, every method finishes in a few milliseconds or less, and nobody would notice a difference. That is the honest takeaway: for a normal-sized address book, even the naive scan is fast enough. The index only earns its keep as the data grows. At 100,000 entries the scan needed about a third of a second to answer four keystrokes, which is long enough to feel sluggish, while the trie needed about a millisecond.

Step 4: searching by phone number

Name search matches the start of words. Number search needs different rules, because people remember numbers differently. They recall the last four digits, or the number without the country code, or they type it with spaces and dashes that were never stored.

The standard solution is to normalise numbers the same way we normalised names. Strip everything that is not a digit, and maybe drop a leading country code such as +91, so that “+91 98765-43210”, “098765 43210” and “9876543210” all become the same string. Then, instead of matching only at the beginning, you let the typed digits match anywhere in the number, or at the end for the “last four digits” habit.

Real-world picture: a security guard asking, “What are the last four digits of your flat number?” People anchor on the end of a number because that is the part they have said out loud the most.

Because digits have only ten possible characters, a similar trie or sorted index works well. For suffix searching, you can index the reversed number so “last four digits” becomes an ordinary prefix lookup.

Step 5: put the most likely person first (ranking)

Finding matches is only half the job. If typing “sha” returns thirty people in alphabetical order, the one you wanted may be on the third screen. So the list has to be ranked.

The idea is to give every match a score from several signals, then sort by score. The signals I always start with are: how well the text matches (the whole name starts with it, a word starts with it, or it appears in the middle), whether you marked the person as a favourite, and how often or how recently you contact them.

from norm import normalise

def score(contact, query):
    """Higher is better. contact = dict(name, favorite, calls)."""
    words = normalise(contact["name"])
    q = normalise(query)
    s = 0
    if " ".join(words).startswith(" ".join(q)):
        s += 100                      # the whole name starts with what you typed
    elif all(any(w.startswith(t) for w in words) for t in q):
        s += 60                       # every typed word starts some word in the name
    elif all(t in " ".join(words) for t in q):
        s += 20                       # typed text appears inside a word
    else:
        return 0
    s += 30 if contact["favorite"] else 0
    s += min(contact["calls"], 50) * 0.5   # frequent contacts float up
    return s

contacts = [
    {"name": "Shalini Rao",   "favorite": False, "calls": 2},
    {"name": "Onam Sharma",   "favorite": False, "calls": 40},
    {"name": "Sharad Verma",  "favorite": True,  "calls": 12},
    {"name": "Ashwin Shah",   "favorite": False, "calls": 5},
    {"name": "Harsh Gupta",   "favorite": False, "calls": 60},
]
query = "sha"
ranked = sorted(((score(c, query), c["name"]) for c in contacts), reverse=True)
for s, name in ranked:
    if s: print(f"{s:6.1f}  {name}")

Running it for the typed text “sha” gives:

 136.0  Sharad Verma
 101.0  Shalini Rao
  80.0  Onam Sharma
  62.5  Ashwin Shah

Notice the order. Sharad Verma is first because his name starts exactly with “sha” and he is a favourite. Shalini Rao is next on pure match quality. Onam Sharma follows, as only the surname starts with “sha”, even though he is called often. Ashwin Shah comes last of the matches, and Harsh Gupta does not appear at all because “sha” never occurs in his name. The weights (100, 60, 20, 30 and the call bonus) are just numbers I chose to demonstrate. In a real product, teams tune them with usage data, and that tuning is the difference between a search that is merely correct and one that feels uncannily helpful.

Step 6: forgiving typos (fuzzy matching)

Thumbs are clumsy and names have many spellings. Is it Anita or Anitha? Sunita or Suneeta? Strict prefix matching treats any typo as a complete failure. Fuzzy matching asks a gentler question: how many small edits would it take to turn what you typed into this name?

The classic measure is edit distance, also called Levenshtein distance, which counts single-letter insertions, deletions and replacements. Here it is in a few lines:

def edit_distance(a, b):
    """Minimum single-letter edits (insert, delete, replace) to turn a into b."""
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j-1] + 1, prev[j-1] + (ca != cb)))
        prev = cur
    return prev[-1]

names = ["Anita", "Amit", "Anil", "Ankit", "Anjali"]
typed = "Anitha"           # another common spelling of "Anita"
for name in sorted(names, key=lambda n: edit_distance(typed.lower(), n.lower())):
    print(f"{name:<7} distance {edit_distance(typed.lower(), name.lower())}")

The output:

Anita   distance 1
Amit    distance 3
Anil    distance 3
Ankit   distance 3
Anjali  distance 4

“Anitha” is one deletion away from “Anita”, so Anita comes first, and everything else is at least three edits away. A search that falls back to fuzzy matching only when the exact search finds nothing gives you the best of both: exact results are fast and clean, and typos still find someone.

One small warning from experience: in plain edit distance, swapping two neighbouring letters (typing “Aniat” for “Anita”) costs two edits, not one, even though it is the most common typing mistake. A variant called Damerau-Levenshtein counts a swap as one. Also, comparing your text against every contact is slow, so real systems use fuzzy matching sparingly, usually on a shortlist, or use clever indexes built for it.

A fun relic: the keypad dialer

Many phone dialers let you type digits on the number pad and show contacts whose names could be spelled with those keys. It is the old keypad letter layout (2 for ABC, 3 for DEF and so on) doing a modern job.

KEYS = {"2":"abc","3":"def","4":"ghi","5":"jkl","6":"mno","7":"pqrs","8":"tuv","9":"wxyz"}
LETTER = {ch: d for d, chars in KEYS.items() for ch in chars}

def to_digits(name):
    return "".join(LETTER[c] for c in name.lower() if c in LETTER)

for name in ["Anita", "Amit", "Anil", "Ankit"]:
    print(f"{name:<6} -> {to_digits(name)}")

typed = "26"                          # you pressed 2 then 6 on the dialer
matches = [n for n in ["Anita", "Amit", "Anil", "Ankit", "Rahul"] if to_digits(n).startswith(typed)]
print("typed", typed, "matches", matches)
Anita  -> 26482
Amit   -> 2648
Anil   -> 2645
Ankit  -> 26548
typed 26 matches ['Anita', 'Amit', 'Anil', 'Ankit']

Pressing 2 then 6 matches every name whose first letter lives on the 2 key and whose second letter lives on the 6 key, which is why Anita, Amit, Anil and Ankit all appear. Add a 4 and Ankit drops out, because its third letter, K, sits on the 5 key. Add an 8 and Anil drops out too, leaving Amit and Anita. Working a few of these by hand is a good way to check that you really understand the mapping.

The same idea in five real settings

This pattern appears far beyond contacts. Click a tab to see it in other places.

Phone contacts

You open the phone app and type three letters of a friend’s name.

DataLocal
MatchWord starts
ExtrasRanking
SpeedInstant

How it searches. Everything lives on the device, so the lookup is local and needs no network. The list matches at the start of any word in the name, can also match digits in a number, and ranks favourites and frequently called people first. The exact internals vary between phone makers and are not published, but this is the standard design.

Email ‘To:’ box

You start typing a colleague’s name in the To field and a small list drops down.

SourcePast contacts
MatchName or address
RankingRecent first
LimitShort list

How it searches. The suggestions are usually drawn from people you have written to and from your address book, then ranked so recent and frequent contacts come first. Matching works on both name and email address, so typing the start of either works. The list is kept short, because a dropdown of fifty names would be slower to scan than to type.

Website search suggestions

You type into a shop’s search bar and suggestions appear below it.

DataOn a server
TrickDebounce
IndexPrefix based
DangerStale replies

How it searches. Here the index lives on a server, so every request costs real time. Pages wait a moment after you stop typing (debouncing) and cancel old requests so the suggestions always match the latest text. The suggestions often come from a prefix index of popular searches, which is the same idea as our trie.

Maps place search

You type “gandhi mar” and the app guesses a road you have never spelled in full.

DataHuge
IndexPrefix + fuzzy
RankingNearby first
TyposForgiven

How it searches. Place search has to cope with millions of names, so it relies on prebuilt indexes, not scans. It typically ranks results by closeness to you or to the map you are viewing, as well as text match, and tolerates misspellings. It shows why ranking signals beyond the text itself matter once the list is large.

Code editor autocomplete

You type get in a code editor and it offers getUser, getUserName and getUsers.

DataSymbols in project
MatchPrefix, initials
RankingContext
SpeedPer keystroke

How it searches. Editors index the names in your project and match what you type against them as you go. Many also match on initials, so typing “gUN” can find getUserName. Ranking uses context, such as which names are valid at the cursor, and how recently you used them. It is search-as-you-type applied to code.

When the data is not on your phone: debouncing and races

Everything so far searched a local list, where each lookup takes microseconds. Things change when the search has to ask a server, such as a company directory with a million staff or a search box on a website. Now each keystroke could trigger a network request taking 100 to 500 milliseconds, and two new problems appear.

  • Too many requests. Typing a seven-letter name could send seven requests in a second. The fix is debouncing: wait until the user pauses for a short time, often 150 to 300 milliseconds, before sending anything. The delay is barely noticeable, and the request count drops sharply.
  • Answers arriving out of order. The reply for “an” might arrive after the reply for “ani”, and the screen ends up showing results for the wrong text. The fix is to cancel the old request, or to tag each request with a number and ignore any reply that is not for the latest one.

Phone contact lists are local, so they rarely need debouncing. A server-backed search box almost always does. Knowing which situation you are in is half the design.

Hard cases that break simple search

  • Names in other scripts. A contact saved in Devanagari or Arabic script cannot be found by typing Latin letters unless the system stores a transliteration or the user switches keyboards. Many real apps index several forms of the same name.
  • Prefixes in surnames. “McDonald”, “de la Cruz” and “Al-Farsi” do not split into words the way English names do. Treat the hyphen and the apostrophe thoughtfully.
  • Accent rules differ by language. Stripping the dots from “ü” helps in French and Spanish but may be wrong in German searches, where “ue” is the usual substitute.
  • Sorting order. The alphabet itself differs between languages. Sorting by raw character codes puts all capitals before all lowercase letters. Use a locale-aware sort.
  • Duplicates. The same person saved twice with slightly different details should usually appear as one result.

Which approach should you choose?

Your situationGood choiceWhy
A few hundred or a few thousand itemsSimple scan, plus reuse of previous resultsFast enough, minimal code, easy to debug.
Tens of thousands of items, prefix searchSorted list with binary searchLean on memory and quick to write.
Very large lists, instant response neededTrie or similar indexLookup cost depends on typed length, not list size.
Users make typosExact search first, fuzzy fallbackKeeps the common case fast and still forgives mistakes.
Results must feel “smart”Ranking by match quality plus usagePuts the likely person first.
Data lives on a serverDebounce and cancel old requestsSaves bandwidth and avoids wrong-order results.

Common mistakes when building search-as-you-type

  • Skipping normalisation. Case and accent mismatches make good data look broken.
  • Matching anywhere instead of at word starts. Typing “an” returns “Hannah” and “Joanna” before “Anita”, because they contain “an” in the middle. Prefer word-start matches and rank anywhere-matches lower.
  • Not ranking at all. An alphabetical list of thirty matches is a correct answer and a poor experience.
  • Rebuilding the index on every keystroke. Build once, update when contacts change.
  • Doing the work on the main thread. Heavy searching on the thread that draws the screen makes the typing itself stutter.
  • Forgetting empty results. Show something helpful, such as “no match” or a fuzzy suggestion, not a blank screen.

Test yourself

Tap a question to reveal the answer. The correct option is marked.

Why can a search reuse the results for “an” when the user types “ani”?
  1. Because the phone remembers everything forever
  2. Every match for “ani” must also be a match for “an”
  3. Because searches are always cached on a server
  4. It cannot, a new full scan is required

Anything that starts with “ani” also starts with “an”, so the longer search can run on the shorter search’s results. It stops working when the user presses backspace.

In a trie, what does the cost of a lookup depend on?
  1. The number of contacts
  2. The number of contacts squared
  3. The length of the text you typed
  4. The alphabet size only

You walk one step per typed letter, so the cost follows the typed length, not the size of the list.

You type “sha” and the list is correct but alphabetical, and your favourite contact is on page three. What is missing?
  1. Ranking
  2. Normalisation
  3. Debouncing
  4. Encryption

Finding matches is only half the job. Scoring by match quality, favourites and how often you contact someone puts the likely person first.

Why debounce a search box that asks a server?
  1. To make the server slower
  2. To hide typos
  3. To sort the results
  4. To avoid sending a request on every keystroke

Waiting a short moment after the last keystroke cuts the number of requests sharply, and cancelling old requests keeps stale replies off the screen.

You typed “Anitha” but the contact is saved as “Anita”. Which technique finds it?
  1. Binary search
  2. Fuzzy matching using edit distance
  3. Debouncing
  4. Trie prefix lookup

Edit distance counts the small changes between two strings. “Anitha” is one deletion away from “Anita”, so it ranks first.

Frequently asked questions

How does contact search work so fast?

It does the heavy work ahead of time. Names are cleaned up and stored in an index, so each keystroke is a quick lookup instead of a scan of every contact. Results are then ranked so the likely person comes first.

What is a trie in simple words?

A tree where each step down spells one more letter. Words that start the same share the same path, so finding everything that begins with a few letters means walking down those letters and reading what lies below.

Why does searching “sh” find a surname that starts with sh?

Good contact search matches the start of any word in the name, not just the first word. That lets you type either the first or the last name.

Do I need a trie for a small app?

Usually not. With a few thousand items, a simple scan is fast enough. In my test, even 10,000 contacts took only a few milliseconds per keystroke for the naive version in Python. Build an index when the data is large or the response must be instant.

How do apps handle spelling mistakes?

Many try an exact prefix search first and, only if that finds little, fall back to fuzzy matching such as edit distance. That keeps common searches fast while still forgiving typos.

What is the difference between debouncing and caching?

Debouncing waits for a pause in typing before sending a request, which reduces how many requests you make. Caching remembers earlier answers so repeating a search needs no new work. Real search boxes often use both.

Key takeaways

  • Search-as-you-type is a new search on every keystroke, so each answer has to be nearly instant.
  • Normalise first: lowercase, strip accents, split into words. Then match the start of any word.
  • Results for a longer text are a subset of results for a shorter one, so you can reuse the previous list. In my test that cut name checks by about two-thirds.
  • An index (a trie or a sorted list with binary search) makes lookup cost depend on what you typed, not on how many contacts you have. At 100,000 contacts the scan was hundreds of times slower.
  • For a normal address book, a simple scan is already fast enough. Do not over-engineer small data.
  • Ranking, fuzzy matching and debouncing are what turn a correct search into one that feels smart.
search as you typeautocompletetriecontact searchprefix searchfuzzy matchingdata structures

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *