7.3 — Search & catalog data¶
Module 7 · Lesson 3 · 🟡 Intermediate · ~35 min
What you'll learn¶
- Why search is the slowest page on most commerce sites, and what to do about it
- Facet counts: the query that quietly doubles your PLP latency
- Typeahead that feels instant without hammering your search service
- Pagination, sorting, and keeping filter state cacheable
Why search is slow¶
Search request lifecycle
├─ Parse and normalize the query 5ms
├─ Spell correction / synonym expansion 25ms
├─ Execute the search 80ms
├─ Compute facet counts across all facets 140ms ← usually the biggest chunk
├─ Apply personalized ranking 60ms
├─ Hydrate results with price + inventory 180ms ← N+1 territory
└─ Render 40ms
─────
530ms
Two of those lines dominate, and both have specific fixes.
Facet counts¶
Faceted navigation ("Colour: Navy (142)") requires counting matching documents for every value of every facet, under the current filter set. It's combinatorially expensive and it's usually computed even when nobody looks at it.
Fixes, in order of impact:
1. Don't compute facets you don't display¶
// ❌ Requesting counts for all 24 facets when the sidebar shows 6
const results = await searchClient.search({
query, filters,
facets: ['*'], // 140ms
});
// ✅ Only what's rendered above the fold
const results = await searchClient.search({
query, filters,
facets: ['category', 'brand', 'color', 'size', 'price_range', 'rating'], // 45ms
});
The remaining facets load on demand when the user expands "More filters":
// Load the rest only when the user opens the drawer
const { data: extraFacets } = useSWR(
drawerOpen ? `/api/facets?${searchParams}` : null,
fetcher,
);
2. Cap facet values¶
facets: {
brand: { limit: 10, sortBy: 'count' }, // 2,400 brands → top 10 + "show all"
color: { limit: 12 },
size: { limit: 20 },
}
Rendering 2,400 checkboxes is bad UX and bad performance — both the count query and the DOM.
3. Cache facet counts separately from results¶
Facet counts change much more slowly than result ordering, and they're identical for every user with the same filter set.
// lib/search.ts
export const getFacetCounts = cache(async (categoryId: string, filters: NormalizedFilters) => {
const key = `facets:${categoryId}:${hashFilters(filters)}`;
return cachedSafe(key, () => searchClient.facetsOnly({ categoryId, filters }), {
ttlSeconds: 300,
tags: [categoryTag(categoryId)],
});
});
4. Normalize the filter key¶
Cache hit ratio depends entirely on the key being stable across equivalent requests.
// lib/normalize-filters.ts
export function normalizeFilters(raw: URLSearchParams): NormalizedFilters {
const out: NormalizedFilters = {};
for (const key of ALLOWED_FACETS) { // allowlist: ignore junk params
const values = raw.getAll(key);
if (values.length) out[key] = [...new Set(values)].sort(); // dedupe + sort
}
return out;
}
// Stable hash for cache keys: ?color=navy&size=m and ?size=m&color=navy
// produce the same key.
export function hashFilters(f: NormalizedFilters): string {
return Object.keys(f).sort().map((k) => `${k}=${f[k].join(',')}`).join('&');
}
This single function is often worth 30–50 points of cache hit ratio. Without it, every ordering of the same filters is a separate cache entry.
Hydrating results with price and inventory¶
The search index has stale prices (it's rebuilt periodically), so you re‑fetch them for the results. Done naively, that's an N+1.
// ❌ 48 calls
const results = await search(query);
const hydrated = await Promise.all(
results.hits.map(async (h) => ({ ...h, price: await getPrice(h.id) })),
);
// ✅ 1 batched call
const results = await search(query);
const ids = results.hits.map((h) => h.id);
const [prices, inventory] = await Promise.all([
pricingService.getMany(ids),
inventoryService.getMany(ids),
]);
const priceById = new Map(prices.map((p) => [p.productId, p]));
const stockById = new Map(inventory.map((i) => [i.productId, i]));
const hydrated = results.hits.map((h) => ({
...h,
price: priceById.get(h.id),
inStock: stockById.get(h.id)?.available ?? true, // fail open on stock
}));
Better still: keep price in the index and accept bounded staleness for display, then confirm the real price on the PDP and in the cart. Aurora indexes base price with a 15‑minute refresh and shows an "as low as" treatment for promo prices — that removed the pricing hydration call from the PLP entirely (−180 ms) at the cost of a display nuance nobody complained about.
Typeahead¶
The highest‑frequency interaction on a commerce site, and a classic way to destroy both INP and your search service's capacity.
// components/search-typeahead.tsx
'use client';
import { useState, useDeferredValue, useRef, useEffect } from 'react';
import useSWR from 'swr';
const MIN_CHARS = 2;
const DEBOUNCE_MS = 180;
export function SearchTypeahead() {
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const deferred = useDeferredValue(query); // keeps rendering interruptible
const abortRef = useRef<AbortController>();
// Debounce the NETWORK call (defer handles the render)
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), DEBOUNCE_MS);
return () => clearTimeout(t);
}, [query]);
const { data, isLoading } = useSWR(
debounced.length >= MIN_CHARS ? ['suggest', debounced] : null,
async ([, q]) => {
abortRef.current?.abort(); // cancel superseded requests
abortRef.current = new AbortController();
const res = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`, {
signal: abortRef.current.signal,
});
return res.json();
},
{
keepPreviousData: true, // no flash of empty state between queries
dedupingInterval: 2000, // identical queries within 2s share one request
revalidateOnFocus: false,
},
);
const isStale = query !== deferred || isLoading;
return (
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
role="combobox"
aria-expanded={!!data?.suggestions?.length}
aria-controls="suggest-list"
aria-autocomplete="list"
/>
{/* Fixed height container → no CLS as suggestions change */}
<ul id="suggest-list" role="listbox" className="min-h-[0] max-h-[320px] overflow-auto"
style={{ opacity: isStale ? 0.7 : 1 }}>
{data?.suggestions?.slice(0, 8).map((s: Suggestion) => (
<li key={s.id} role="option" aria-selected={false}>
<a href={s.url}>{s.label}</a>
</li>
))}
</ul>
</div>
);
}
The five rules of fast typeahead:
- Debounce the network (150–250 ms), defer the render. These are different problems; do both.
- Minimum 2–3 characters. Single‑character queries match everything and are the most expensive.
- Cap at 8 suggestions. Rendering 50 costs more than fetching them.
- Abort superseded requests. Otherwise a slow response for "co" overwrites results for "coat".
- Cache aggressively at the edge. Suggestion queries follow a steep power law — the top 1,000 prefixes cover most traffic.
// app/api/suggest/route.ts
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.toLowerCase().trim() ?? '';
if (q.length < 2) return Response.json({ suggestions: [] });
const suggestions = await searchClient.suggest(q, { limit: 8 });
return Response.json({ suggestions }, {
headers: {
// Prefixes are highly repetitive across users — cache hard at the edge
'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=86400',
},
});
}
With edge caching, Aurora's suggestion endpoint went from 210 ms p75 to 24 ms, and the search service's QPS dropped 91%.
Pagination vs infinite scroll¶
| Pagination | Infinite scroll | |
|---|---|---|
| Cacheable | ✅ Per page | ⚠️ Per page, but URL sync is manual |
| Crawlable | ✅ Distinct URLs | ❌ Needs a fallback |
| Memory | Bounded | Grows unbounded |
| INP over a session | Stable | Degrades |
| Footer reachable | ✅ | ❌ |
| Shopper expectation | Familiar in commerce | Familiar in feeds |
Default to pagination for commerce. Use infinite scroll only where research shows it converts better for your audience, and cap it (5.4).
Deep pagination is a trap¶
Offset pagination degrades badly at depth. Two mitigations:
// 1. Cap depth. Nobody legitimately browses to page 500 — it's a scraper,
// and deep pages have no SEO value anyway.
const MAX_PAGE = 50;
if (page > MAX_PAGE) notFound();
// 2. Cursor pagination for infinite scroll (no offset computation)
const results = await search({ query, after: cursor, limit: 48 });
// Response includes `endCursor` for the next request.
Also add rel="canonical" on paginated pages pointing at themselves (not page 1), and make sure
paginated URLs are crawlable but low‑priority in your sitemap.
Sorting¶
Sort changes usually invalidate your cache entirely. Two mitigations:
// 1. Allowlist sorts — an open `sort` param is a cache-fragmenting free-for-all
const ALLOWED_SORTS = ['featured', 'price_asc', 'price_desc', 'newest', 'rating'] as const;
type Sort = typeof ALLOWED_SORTS[number];
function parseSort(raw: string | undefined): Sort {
return ALLOWED_SORTS.includes(raw as Sort) ? (raw as Sort) : 'featured';
}
// 2. Cache the top N results per (category, sort) combination — a small,
// bounded set that covers most traffic.
// 11K categories × 5 sorts = 55K entries. Manageable.
const key = `plp:${categoryId}:${sort}:${page}`;
Don't cache per (category × sort × every filter combination) — that space is unbounded. Cache the unfiltered sorted pages, and let filtered views hit the search service with warm data behind it.
Keeping search off the critical path¶
Search results are inherently dynamic, but the page doesn't have to be.
// app/search/page.tsx
export default async function SearchPage({ searchParams }) {
const { q } = await searchParams;
return (
<>
{/* Static shell: header, search box with the query pre-filled, filters chrome.
Paints immediately — TTFB is not gated on the search query. */}
<SearchHeader query={q} />
{/* The slow part streams in */}
<Suspense key={q} fallback={<ResultsSkeleton count={12} />}>
<SearchResults query={q} />
</Suspense>
</>
);
}
async function SearchResults({ query }: { query: string }) {
const results = await searchProducts(query);
if (!results.hits.length) return <NoResults query={query} suggestions={results.didYouMean} />;
return <ProductGrid products={results.hits} />;
}
Note the key={q} on the Suspense boundary: on a new search you want the skeleton (the previous
results are wrong). Within a filter refinement wrapped in a transition, you don't. Choose
deliberately per interaction.
Zero‑result pages¶
Not a performance topic on its face, but zero‑result pages are a large share of search traffic and they're often the slowest (the engine tried every fallback strategy before giving up).
// Bound the fallback chain
const results = await searchClient.search(query, {
removeStopWords: true,
typoTolerance: 'min',
// Cap how much work the engine does chasing a match
maxFacetHits: 20,
timeout: 600,
});
if (!results.hits.length) {
// Serve a cached "popular products" fallback instead of another expensive query
const popular = await getCachedPopularProducts(categoryHint);
return { hits: [], fallback: popular, didYouMean: results.didYouMean };
}
Aurora's search results¶
| Change | Search p75 TTFB |
|---|---|
| Baseline | 1,240 ms |
| Streaming shell (query not blocking TTFB) | 210 ms |
| Facet allowlist (24 → 6 facets) | 210 ms (results 530 → 435 ms) |
| Price/stock batched instead of N+1 | (results 435 → 255 ms) |
| Base price in the index | (results 255 → 190 ms) |
| Filter key normalization (hit ratio 31% → 88%) | (results p75 → 62 ms on hits) |
| Suggestion endpoint edge‑cached | typeahead 210 → 24 ms |
Common mistakes¶
| Mistake | Cost |
|---|---|
| Computing all facets on every request | 100–200 ms per search |
| N+1 price/inventory hydration | 150–250 ms |
| Unnormalized filter keys | Cache hit ratio collapse |
| Typeahead without debounce | A request per keystroke; service overload |
| Typeahead without abort | Out‑of‑order results overwrite correct ones |
| Rendering 50 suggestions | INP cost bigger than the fetch |
Unbounded sort parameter |
Cache fragmentation |
| Deep offset pagination uncapped | Expensive queries; scraper magnet |
| Blocking TTFB on the search query | Slowest page on the site |
Lab 7.3 — Search performance¶
- Instrument the search pipeline with
Server-Timing: parse, search, facets, hydration. Find the dominant phase. - Count your facets. How many are requested vs displayed above the fold?
- Check for N+1 in result hydration. Batch it.
- Normalize your filter keys and measure the cache hit ratio before/after.
- Audit typeahead: requests per keystroke (Network panel while typing "wool coat"), abort behavior, suggestion count, and edge cache headers.
- Stream the search shell so TTFB isn't gated on the query.
- Cap pagination depth and allowlist sorts.
Checklist¶
- Only displayed facets are requested; the rest load on demand
- Facet values capped per facet
- Facet counts cached separately with normalized keys
- Result hydration batched, never N+1
- Filter keys normalized (allowlisted, deduped, sorted)
- Typeahead: debounced network, deferred render, aborted requests, ≤ 8 suggestions
- Suggestion endpoint edge‑cached with SWR
- Search shell streams; TTFB independent of query latency
- Sorts allowlisted; pagination depth capped
- Zero‑result path bounded and falls back to cached popular products
Next: 7.4 Cart & checkout data