Search and Predictive Search Intermediate 10 min read

Performance Considerations for Search

Optimize search performance with debouncing, request cancellation, lazy loading, caching strategies, and efficient rendering.

Search performance directly impacts user experience. Slow or janky search frustrates customers and can hurt conversions. Let’s explore optimization techniques for fast, responsive search.

Debouncing Input

Prevent excessive API calls while typing:

class PredictiveSearch extends HTMLElement {
constructor() {
super();
this.debounceTimer = null;
this.debounceDelay = 300; // milliseconds
}
onInput(event) {
const query = event.target.value.trim();
// Clear previous timer
clearTimeout(this.debounceTimer);
// Don't search for very short queries
if (query.length < 2) {
this.close();
return;
}
// Wait for user to stop typing
this.debounceTimer = setTimeout(() => {
this.search(query);
}, this.debounceDelay);
}
}

Why 300ms? It’s a balance between responsiveness and efficiency. Too short creates many requests; too long feels sluggish.

Cancelling Previous Requests

Avoid race conditions with AbortController:

class PredictiveSearch extends HTMLElement {
constructor() {
super();
this.abortController = null;
}
async search(query) {
// Cancel any in-flight request
if (this.abortController) {
this.abortController.abort();
}
// Create new controller for this request
this.abortController = new AbortController();
try {
const response = await fetch(
`/search/suggest?q=${encodeURIComponent(query)}&section_id=predictive-search`,
{ signal: this.abortController.signal }
);
if (!response.ok) throw new Error('Search failed');
const html = await response.text();
this.renderResults(html);
} catch (error) {
// Ignore abort errors (they're intentional)
if (error.name === 'AbortError') return;
console.error('Search error:', error);
}
}
}

Minimum Query Length

Skip searches for very short queries:

const MIN_QUERY_LENGTH = 2;
onInput(event) {
const query = event.target.value.trim();
if (query.length < MIN_QUERY_LENGTH) {
this.close();
return;
}
// Proceed with search
}

Limiting Results

Request only what you need:

/search/suggest?q=shirt&resources[limit]=4&resources[type]=product

Fewer results means:

  • Smaller response size
  • Faster rendering
  • Less DOM manipulation

Lazy Loading Images

Don’t block search results for images:

<img
src="{{ product.featured_image | image_url: width: 100 }}"
alt="{{ product.title }}"
loading="lazy"
decoding="async"
width="50"
height="50"
>

Or use a placeholder pattern:

<div class="search-result__image" data-src="{{ product.featured_image | image_url: width: 100 }}">
<div class="search-result__placeholder"></div>
</div>
// Lazy load images when they enter viewport
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = document.createElement('img');
img.src = entry.target.dataset.src;
img.onload = () => entry.target.appendChild(img);
observer.unobserve(entry.target);
}
});
});
document.querySelectorAll('[data-src]').forEach(el => observer.observe(el));

Caching Results

Cache recent searches to avoid duplicate requests:

class PredictiveSearch extends HTMLElement {
constructor() {
super();
this.cache = new Map();
this.cacheMaxSize = 20;
this.cacheTTL = 60000; // 1 minute
}
async search(query) {
const normalizedQuery = query.toLowerCase().trim();
// Check cache first
const cached = this.cache.get(normalizedQuery);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
this.renderResults(cached.html);
return;
}
// Fetch fresh results
const html = await this.fetchResults(query);
// Store in cache
this.cacheResult(normalizedQuery, html);
this.renderResults(html);
}
cacheResult(query, html) {
// Limit cache size
if (this.cache.size >= this.cacheMaxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(query, {
html,
timestamp: Date.now()
});
}
}

Optimizing Render Performance

Use DocumentFragment

Batch DOM updates:

renderResults(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const fragment = document.createDocumentFragment();
doc.querySelectorAll('.search-result').forEach(result => {
fragment.appendChild(result.cloneNode(true));
});
// Single DOM update
this.resultsContainer.innerHTML = '';
this.resultsContainer.appendChild(fragment);
}

Avoid Layout Thrashing

Batch reads and writes:

// Bad: Causes layout thrashing
items.forEach(item => {
const height = item.offsetHeight; // Read
item.style.height = height + 'px'; // Write
});
// Good: Batch reads, then batch writes
const heights = items.map(item => item.offsetHeight); // All reads
items.forEach((item, i) => {
item.style.height = heights[i] + 'px'; // All writes
});

Load the search section on hover:

let searchPreloaded = false;
document.querySelector('[data-search-toggle]').addEventListener('mouseenter', () => {
if (searchPreloaded) return;
// Preload the predictive search section
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = '/?section_id=predictive-search';
document.head.appendChild(link);
searchPreloaded = true;
}, { once: true });

Skeleton Loading

Show placeholder content immediately:

<div class="predictive-search__loading">
{%- for i in (1..4) -%}
<div class="search-skeleton">
<div class="search-skeleton__image"></div>
<div class="search-skeleton__content">
<div class="search-skeleton__title"></div>
<div class="search-skeleton__price"></div>
</div>
</div>
{%- endfor -%}
</div>
.search-skeleton {
display: flex;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
}
.search-skeleton__image {
width: 50px;
height: 50px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--border-radius);
}
.search-skeleton__title {
width: 150px;
height: 16px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.search-skeleton__price {
width: 80px;
height: 14px;
margin-top: 4px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}

Network-Aware Optimization

Adjust behavior based on connection:

class PredictiveSearch extends HTMLElement {
constructor() {
super();
this.debounceDelay = this.getDebounceDelay();
}
getDebounceDelay() {
const connection = navigator.connection;
if (!connection) return 300;
// Slower connections get longer debounce
if (connection.effectiveType === '2g') return 500;
if (connection.effectiveType === '3g') return 400;
if (connection.saveData) return 500;
return 300;
}
}

Measuring Performance

Track search performance:

async search(query) {
const startTime = performance.now();
try {
const response = await this.fetchResults(query);
const endTime = performance.now();
const duration = endTime - startTime;
// Log slow searches
if (duration > 1000) {
console.warn(`Slow search: ${query} took ${duration}ms`);
}
// Optional: Send to analytics
if (window.gtag) {
gtag('event', 'search_timing', {
search_term: query,
value: Math.round(duration)
});
}
this.renderResults(response);
} catch (error) {
// Handle error
}
}
class PredictiveSearch extends HTMLElement {
constructor() {
super();
this.input = this.querySelector('input[type="search"]');
this.resultsContainer = this.querySelector('[data-results]');
this.loadingContainer = this.querySelector('[data-loading]');
this.debounceTimer = null;
this.debounceDelay = 300;
this.abortController = null;
this.cache = new Map();
this.minQueryLength = 2;
}
connectedCallback() {
this.input.addEventListener('input', this.onInput.bind(this));
this.input.addEventListener('focus', this.onFocus.bind(this));
}
onInput(event) {
const query = event.target.value.trim();
clearTimeout(this.debounceTimer);
if (query.length < this.minQueryLength) {
this.close();
return;
}
// Show loading immediately
this.showLoading();
this.debounceTimer = setTimeout(() => {
this.search(query);
}, this.debounceDelay);
}
async search(query) {
const normalizedQuery = query.toLowerCase();
// Check cache
const cached = this.cache.get(normalizedQuery);
if (cached && Date.now() - cached.time < 60000) {
this.renderResults(cached.html);
return;
}
// Cancel previous request
this.abortController?.abort();
this.abortController = new AbortController();
try {
const response = await fetch(
`/search/suggest?q=${encodeURIComponent(query)}&section_id=predictive-search&resources[limit]=4`,
{ signal: this.abortController.signal }
);
if (!response.ok) throw new Error('Failed');
const html = await response.text();
// Cache result
this.cache.set(normalizedQuery, { html, time: Date.now() });
// Limit cache size
if (this.cache.size > 20) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.renderResults(html);
} catch (error) {
if (error.name !== 'AbortError') {
this.showError();
}
}
}
showLoading() {
this.loadingContainer.hidden = false;
this.resultsContainer.hidden = true;
}
renderResults(html) {
this.loadingContainer.hidden = true;
this.resultsContainer.innerHTML = html;
this.resultsContainer.hidden = false;
}
showError() {
this.loadingContainer.hidden = true;
this.resultsContainer.innerHTML = '<p>Something went wrong</p>';
this.resultsContainer.hidden = false;
}
close() {
this.loadingContainer.hidden = true;
this.resultsContainer.hidden = true;
}
}
customElements.define('predictive-search', PredictiveSearch);

Practice Exercise

Optimize a predictive search to:

  1. Debounce input (300ms)
  2. Cancel previous requests
  3. Cache results for 1 minute
  4. Show skeleton loading
  5. Handle slow connections

Measure improvements with DevTools Performance tab.

Key Takeaways

  1. Debounce input to reduce API calls
  2. Cancel requests with AbortController
  3. Cache results for repeated queries
  4. Lazy load images to not block rendering
  5. Use skeleton loaders for perceived speed
  6. Limit results to what you need
  7. Measure performance and optimize bottlenecks
  8. Consider network conditions for delays

Module Complete!

Congratulations on completing Module 12! You’ve mastered:

  • Search template fundamentals
  • Predictive search concepts and UI
  • Rendering search results
  • No-results UX handling
  • Search performance optimization

Next up: Module 13: Content Pages and 404 for building static pages and error handling.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...