Skip to content
ExponaBLOG
ELASTICSEARCH & SEARCH ENGINEERING · DAY 020 / 100

Autocomplete with Elasticsearch: lessons from production

Search-as-you-type suggestions.

Expona Admin6 min read

Autocomplete with Elasticsearch: lessons from production

Search-as-you-type suggestions can make or break the user experience in any application where discovery matters. We've learned the hard way that autocomplete isn't just about typing speed—it's about latency, relevance, and knowing when to use the right Elasticsearch feature for the job. In this post, we'll walk through the approaches we've refined across production systems handling millions of daily searches.

Why autocomplete matters in production

Autocomplete surfaces results before users finish typing, reducing friction and guiding them toward what they're looking for. In ride-hailing, location search needs to suggest destinations within 100ms. In e-commerce, product autocomplete drives conversion. In SaaS, finding the right customer or report matters for productivity. Elasticsearch gives us multiple tools; choosing the wrong one costs you in latency or flexibility.

search_as_you_type field — Built-in field type for prefixes

Elasticsearch's search_as_you_type field type was designed specifically for this use case. It automatically creates prefix-aware subfields and handles prefix matching without custom analyzers. The field type generates shingles at index time, making queries extremely fast.

Here's how to set it up:

json
{
  "mappings": {
    "properties": {
      "title": {
        "type": "search_as_you_type"
      },
      "location": {
        "type": "search_as_you_type",
        "max_shingle_size": 3
      }
    }
  }
}

This creates three subfields: title (the original field), title.2gram, and title.3gram. Query time is where the magic happens:

json
{
  "query": {
    "multi_match": {
      "query": "new yor",
      "type": "bool_prefix",
      "fields": [
        "location",
        "location.2gram",
        "location.3gram"
      ]
    }
  }
}

We've used this in travel systems for destination autocomplete. It consistently returned under 50ms for millions of locations. The bool_prefix query type matches all complete terms and the final term as a prefix, which aligns perfectly with how users type.

The trade-off: search_as_you_type consumes more disk space than standard text fields because it stores multiple n-gram variants. For a dataset with 5 million products, expect 20-30% higher index size. That's usually acceptable given the query speed gain.

Edge n-grams — Custom analyzer for prefix matching

When you need more control—say, custom tokenization rules, language-specific stemming, or integration with existing analyzers—edge n-grams give you that flexibility. An edge n-gram tokenizer generates prefixes of varying length, only from the beginning of a term.

Set up a custom analyzer:

json
{
  "settings": {
    "analysis": {
      "analyzer": {
        "autocomplete": {
          "type": "custom",
          "tokenizer": "autocomplete",
          "filter": ["lowercase"]
        }
      },
      "tokenizer": {
        "autocomplete": {
          "type": "edge_ngram",
          "min_gram": 1,
          "max_gram": 20,
          "token_chars": ["letter", "digit"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "product_name": {
        "type": "text",
        "analyzer": "autocomplete",
        "search_analyzer": "standard"
      }
    }
  }
}

The key detail: use autocomplete analyzer at index time, but switch to standard (or a simpler analyzer) at search time. This prevents the query from being tokenized as edge n-grams, which would cause unwanted matching behavior.

When you index "laptop", the field stores: l, la, lap, lapt, lapto, laptop. Querying for "lap" matches immediately. We've used this approach in CRM systems where custom tokenization for company names or account codes was non-negotiable.

The downside: edge n-grams generate many tokens, ballooning index size faster than search_as_you_type. You lose some control over what gets indexed—all prefixes are treated equally in scoring. For fuzzy matching or typo tolerance, you'd layer in additional techniques.

Completion suggester — Very fast, limited flexibility

When you absolutely need the fastest possible response and can sacrifice flexibility, the completion suggester is your answer. It's optimized at the data structure level for speed, backed by a finite-state transducer (FST). It's orders of magnitude faster than full-text search.

Mapping:

json
{
  "mappings": {
    "properties": {
      "name": {
        "type": "completion"
      },
      "category": {
        "type": "keyword"
      }
    }
  }
}

Query:

json
{
  "suggest": {
    "product_suggestions": {
      "prefix": "lapt",
      "completion": {
        "field": "name",
        "size": 10,
        "skip_duplicates": true
      }
    }
  }
}

Response times are routinely under 10ms for hundreds of thousands of suggestions. We've deployed this for ride-hailing driver name lookup and internal SaaS command palettes where milliseconds matter.

The limitations: the completion suggester doesn't support complex queries or filtering within the suggester itself (though you can filter the results post-query). It doesn't understand relevance the way full-text search does. If your autocomplete results need to be ranked by popularity, recency, or category, you'll need to handle that logic outside the suggestion phase or augment it with a follow-up query.

Debounce on the client — Send requests after short pauses

No matter which Elasticsearch approach you choose, the most impactful optimization happens before the query reaches your cluster: debouncing on the client.

Every keystroke triggers a potential request. Users type quickly. If you send a request for every keystroke—"n", "ne", "new", "new ", "new y", "new yo", "new yor", "new york"—you're multiplying your load by 8x and most of those queries are useless because the user is still typing.

Implement debounce in your frontend:

json
// React example with useEffect
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);

useEffect(() => {
  const timer = setTimeout(async () => {
    if (query.length < 2) {
      setSuggestions([]);
      return;
    }
    const response = await fetch(`/api/autocomplete?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setSuggestions(data.suggestions);
  }, 300); // 300ms debounce

  return () => clearTimeout(timer);
}, [query]);

A 300ms debounce window is our standard. It feels instant to users (imperceptible delay), but eliminates 70-90% of unnecessary requests in practice. For slower networks, increase to 500ms. For high-traffic systems, even this difference scales significantly.

Combine debouncing with request cancellation to handle cases where a user types, pauses, then types again:

json
const controllerRef = useRef(new AbortController());

useEffect(() => {
  const timer = setTimeout(async () => {
    controllerRef.current.abort();
    controllerRef.current = new AbortController();
    
    if (query.length < 2) return;
    
    try {
      const response = await fetch(`/api/autocomplete?q=${query}`, {
        signal: controllerRef.current.signal
      });
      const data = await response.json();
      setSuggestions(data.suggestions);
    } catch (err) {
      if (err.name !== 'AbortError') throw err;
    }
  }, 300);

  return () => clearTimeout(timer);
}, [query]);

This prevents slow requests from overwriting newer results. We've seen this pattern reduce backend load by 50% while improving perceived responsiveness in e-commerce and travel products.

Common mistakes

  • Sending every keystroke to Elasticsearch: Without client-side debouncing, you'll overwhelm your cluster and degrade user experience. Always debounce.
  • Using the wrong analyzer for your domain: Mixing language analyzers, forgetting to lowercase, or applying the wrong tokenizer for your data type leads to missed matches. Test with your actual data.
  • Querying too many results: Requesting 100 suggestions when you'll only display 5-10 wastes resources. Query for what you need, plus a small buffer for filtering.
  • Forgetting search_analyzer in edge n-gram setups: If you don't specify a separate search analyzer, queries also get tokenized as edge n-grams, causing unexpected matches or missed results.
  • Ignoring index size growth: Prefix-matching approaches cost disk space. Monitor your index size growth and plan accordingly, especially in sharded clusters.
  • Over-relying on completion suggester for complex logic: If you need filtering, multi-field ranking, or business logic in suggestions, completion suggester alone isn't enough.

Key takeaways

  • search_as_you_type is the pragmatic default: fast, built-in, minimal configuration. Use it unless you have a specific reason not to.
  • Edge n-grams give you control over tokenization and integration with custom analyzers; they trade disk space for flexibility.
  • Completion suggester is the speed champion for simple prefix matching; use it when latency is critical and you don't need complex filtering or ranking.
  • Debounce on the client at 300-500ms to eliminate most unnecessary queries and reduce backend load by 50%+.
  • Measure latency, index size, and query volume in production before choosing your approach. What works for a small catalog may not scale to millions of records.
  • Combine Elasticsearch-level optimization (field type + analyzer) with client-side optimization (debounce + cancellation) for the best user experience.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles