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

Search UX best practices: a hands-on guide for engineers

Great search is more than a query.

Expona Admin8 min read

Search UX best practices: a hands-on guide for engineers

We've all clicked a search box and waited. And waited. Then typed something and got no results—or worse, irrelevant ones. Search UX best practices: a hands-on guide for engineers isn't about query syntax or relevance scoring algorithms alone; it's about building the human experience around the search engine itself. When we get this right, search becomes invisible—users find what they need without friction.

Over the past 75 days, we've built indexes, tuned analyzers, and optimized queries. Now it's time to wrap all that work in an interface that actually helps people. This guide walks you through the hands-on decisions that separate "search works" from "search delights."

Instant results—Autocomplete and fast responses

Users expect feedback the moment they start typing. Autocomplete isn't a nice-to-have; it's table stakes. When autocomplete is smooth, users gain confidence. They see their intent reflected back immediately, which reduces keystroke errors and abandonment.

In Elasticsearch, we typically use a combination of completion suggesters and prefix queries. The completion suggester is built for speed—it's optimized at index time and can return suggestions in milliseconds, even with millions of options.

json
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "product_name": {
        "type": "text"
      },
      "suggest": {
        "type": "completion",
        "analyzer": "simple"
      }
    }
  }
}

When indexing, we populate both fields. The suggest field gets the completion suggester treatment, while product_name remains a full-text field for the actual search:

json
POST /products/_doc/1
{
  "product_name": "Wireless Noise-Cancelling Headphones",
  "suggest": {
    "input": ["wireless", "noise cancelling", "headphones", "wnch"],
    "weight": 100
  }
}

The query is simple but effective:

json
GET /products/_search
{
  "suggest": {
    "product-suggestions": {
      "prefix": "wire",
      "completion": {
        "field": "suggest",
        "size": 10,
        "skip_duplicates": true,
        "fuzzy": {
          "fuzziness": "AUTO"
        }
      }
    }
  }
}

A few production considerations: Keep autocomplete responses under 100ms by limiting field data and using caching aggressively. On the frontend, debounce requests by 200–300ms to avoid hammering the backend on every keystroke. Show results as soon as they arrive; users will tolerate a short spinner over a delayed response.

Also consider typo tolerance. The fuzzy parameter with AUTO fuzziness gives us one free edit distance for shorter terms, which catches most common typos without bloating the suggester index.

Handle no results—Suggestions and corrected queries

Empty result sets are demoralizing. A user types something specific, gets zero hits, and often just leaves. Our job is to redirect that moment into a path forward.

We handle this with a two-pronged approach: query correction and fallback suggestions.

First, query correction. If someone searches for "iphone 15 pro max" but our product database uses "iPhone 15 Pro Max," we want to catch that. Elasticsearch's match_phrase query with fuzziness can help, but for real power, we use a suggester:

json
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "iphone 15 pro max",
            "fields": ["name^2", "description"],
            "operator": "or",
            "fuzziness": "AUTO"
          }
        }
      ]
    }
  },
  "suggest": {
    "text": "iphone 15 pro max",
    "did-you-mean": {
      "phrase": {
        "field": "name.suggest",
        "size": 3,
        "direct_generator": [
          {
            "field": "name.suggest",
            "suggestion_mode": "always"
          }
        ]
      }
    }
  }
}

If the fuzzy query returns nothing, we inspect the suggest block. The phrase suggester offers corrections that respect term frequency and common misspellings. If we still have zero results, we show these suggestions to the user: "Did you mean iPhone 15 Pro Max?" or "You might like these popular searches."

For fallback suggestions, store a list of popular, broad categories in your application layer. When a query fails, offer three to five related categories or best sellers. This transforms a dead end into an entry point for discovery.

json
{
  "type": "no-results",
  "user_query": "foldable smartwatch",
  "suggestions": [
    {
      "text": "Browse smartwatches",
      "facet": "category",
      "value": "smartwatch"
    },
    {
      "text": "View wearables on sale",
      "facet": "promotion",
      "value": "sale"
    }
  ]
}

Log every no-result event. Over time, these logs become a goldmine for understanding user intent and gaps in your index. Many successful teams build a "zero-results dashboard" that feeds back into content and merchandising teams.

Facets—Clear filters with counts

Faceted navigation is how users progressively refine results. A good facet sidebar reduces cognitive load by offering concrete options rather than forcing users to formulate new queries.

In Elasticsearch, we use aggregations to build facets. The key is to return both the facet values and the count of documents matching each value:

json
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "name": "headphones"
          }
        }
      ]
    }
  },
  "aggs": {
    "brand": {
      "terms": {
        "field": "brand.keyword",
        "size": 10
      }
    },
    "price_range": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 50 },
          { "from": 50, "to": 150 },
          { "from": 150, "to": 500 },
          { "from": 500 }
        ]
      }
    },
    "rating": {
      "terms": {
        "field": "rating_stars",
        "size": 5
      }
    }
  },
  "size": 20
}

The response gives us facet values and counts:

json
{
  "aggregations": {
    "brand": {
      "buckets": [
        { "key": "Sony", "doc_count": 342 },
        { "key": "Bose", "doc_count": 287 },
        { "key": "Apple", "doc_count": 156 }
      ]
    },
    "price_range": {
      "buckets": [
        { "key": "*-50", "doc_count": 89 },
        { "key": "50-150", "doc_count": 201 },
        { "key": "150-500", "doc_count": 195 },
        { "key": "500-*", "doc_count": 45 }
      ]
    }
  }
}

Display these counts next to each facet option. A user sees "Sony (342)" and knows there are hundreds of options in that brand. Counts build trust and set expectations. Without them, facets feel like a guessing game.

One gotcha: if you apply a filter (e.g., "Show only Sony products"), your facet aggregations will shrink to show only filtered results. To show the full set of facets even when a filter is active—a pattern called "dynamic facets"—wrap your aggregations in a separate context:

json
{
  "aggs": {
    "all_brands": {
      "filter": {
        "match": { "name": "headphones" }
      },
      "aggs": {
        "brands": {
          "terms": { "field": "brand.keyword", "size": 10 }
        }
      }
    }
  }
}

This ensures brand facets remain visible and fully populated even after the user has narrowed to a specific price range.

Mobile—Large tap targets and simple filters

Mobile users have different constraints. Screens are smaller, touch targets are larger, and typing is slower. Your search UX must adapt.

On mobile, we recommend a collapsed filter panel. The main search results take up the full viewport; a tap opens a bottom sheet or full-screen overlay with facets. This keeps the focus on results while making filters easily accessible.

For the search input itself, use a mobile-friendly keyboard. On iOS and Android, you can hint at the expected input type. Mobile browsers often provide native autocomplete and spelling correction, so don't fight it—let the browser help:

json
<input 
  type="search" 
  placeholder="Search products" 
  autocomplete="off" 
  autocorrect="off" 
  spellcheck="false" 
  inputmode="search"
>

The type="search" attribute triggers search-specific UI on mobile (including a dedicated search key on the soft keyboard). Set inputmode="search" to ensure consistent behavior across browsers.

Facet buttons and checkboxes should be at least 44x44 pixels (the recommended touch target size). On mobile, favor larger, simpler filters over a wall of options. If you have 20 potential brands, show the top 6 or 8 and hide the rest behind "Show more."

Consider the network. Mobile connections can be spotty. Implement optimistic UI updates: when the user taps a filter, update the UI immediately while the request is in flight. If the request fails, roll back gracefully. Users perceive this as faster, even if the backend response time is identical.

Also, avoid loading facet counts on every mobile filter tap if the backend call is expensive. Cache facet aggregations server-side or use a stale-while-revalidate pattern: show cached counts while fetching fresh ones in the background.

Common mistakes

  • Ignoring latency in autocomplete. Every 100ms of delay reduces interaction rates noticeably. Profile your completion suggester queries; if they exceed 50ms, you need caching or a smaller corpus.
  • No feedback when search is loading. A silent spinner erodes trust. Show a loading indicator immediately, and stream results as they arrive if possible.
  • Facet counts that lie. If facet counts don't match the actual filtered result count, users lose confidence. Test this explicitly in your QA process.
  • Overloading facets. More options isn't better. 5–7 well-chosen facets outperform 20 options that overwhelm the user.
  • Forgetting to log search behavior. Track searches that returned zero results, common refinements, and facets users ignore. This data drives real improvements.
  • Not testing on real devices. Desktop and mobile are different worlds. Network throttle your development connection and test on actual phones running slower networks.

Key takeaways

  • Autocomplete with a completion suggester reduces friction and improves perceived speed. Aim for sub-100ms responses with aggressive caching.
  • When a query returns no results, offer corrected queries and category-based suggestions. Log these events to drive content and feature decisions.
  • Facet counts build trust and guide user exploration. Use aggregations to show accurate counts, and implement dynamic facets to keep them visible during filtering.
  • Mobile search demands adapted UI: larger tap targets, collapsed filter panels, and optimistic updates. Test on real networks and devices.
  • Search UX is an iterative process. Instrument your search interface, measure user behavior, and refine constantly. A well-designed search experience compounds over time as it learns what your users actually need.
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