hardSystem Design

Search Engine Results Page (e.g. Google Search)

Design a search results page with instant suggestions, federated result types (web, images, news, knowledge panels), progressive rendering, and sub-200ms perceived latency.

45 min read

Problem Statement

A Search Engine Results Page (SERP) is the most latency-sensitive page on the internet. Google processes 8.5 billion searches per day — each one demanding sub-second rendering of heterogeneous content: organic links, featured snippets, knowledge panels, image carousels, video results, maps, shopping listings, and "People Also Ask" accordions. The frontend must orchestrate federated results from dozens of backend services, stream them progressively, and render a pixel-perfect page within 400ms of keystroke.

This is not a simple list. A modern SERP is a layout engine that dynamically composes 15+ result card types, handles instant search suggestions with keyboard navigation, maintains URL-driven state for shareability, tracks click-through rates for ranking feedback, and supports voice input — all while hitting Core Web Vitals targets that directly impact the search engine's own brand credibility.

Real-world implementations: Google Search, Bing, DuckDuckGo, Elasticsearch UI (Kibana), Algolia InstantSearch, Meilisearch UI, and Typesense SearchBar.


Requirements Exploration

Functional Requirements

  1. Instant search suggestions — typeahead dropdown with ≤100ms response, keyboard navigation (↑↓ Enter Esc), recent searches, trending queries
  2. Federated result types — web links, images, news, videos, knowledge panels, maps, shopping, featured snippets, People Also Ask, sitelinks
  3. Result card rendering — each type renders differently: snippets with query highlighting, sitelinks grid, image thumbnails, video previews with duration
  4. Progressive/streaming rendering — stream results as backend services respond; show skeleton → partial → complete
  5. URL-driven state — query q, page p, filters tbm (type), time range tbs, SafeSearch safe — all in URL for bookmarking/sharing
  6. Search filters — tabs (All, Images, News, Videos, Maps, Shopping) + sidebar refinements (time range, verbatim, region)
  7. Pagination — numbered pages with "Next" for web results; infinite scroll for image grid
  8. Knowledge panel — structured entity card with facts, images, related entities, social links
  9. People Also Ask — expandable accordion with lazy-loaded answers
  10. Voice search — Web Speech API integration, animated microphone UI, query-by-voice
  11. Image search grid — masonry layout, SafeSearch toggle, hover preview, color filter
  12. Click tracking — CTR measurement per position, dwell time signals, A/B testing of result layouts

Non-Functional Requirements

RequirementTargetRationale
Time to First Result≤400ms (P50), ≤800ms (P95)Users abandon after 1s
LCP≤1.2sFirst visible result card
INP≤100msSuggestion selection, filter toggling
CLS0Results must not shift as late content loads
Suggestion latency≤100msTypeahead must feel instant
Bundle size (initial)≤80KB gzippedCritical path JS only
Availability99.99%Core product SLA
Concurrent users500K+ simultaneousPeak traffic handling
AccessibilityWCAG 2.2 AALegal requirement + user base
SEO (of SERP itself)Lighthouse 95+Brand credibility

Capacity Estimation & Constraints

Daily searches:           8.5 billion
Peak QPS:                 ~150,000
Avg results per query:    10 (page 1) + suggestions + knowledge panel
Avg response payload:     45KB (compressed JSON)
Suggestion requests/search: 4-6 (per keystroke after debounce)
Peak suggestion QPS:      ~700,000
Image results per grid:   20-40 thumbnails (lazy loaded)

Client constraints:
- Mobile: 60% of traffic, 3G+ networks
- Average device: mid-range Android (4GB RAM, Snapdragon 600-series)
- Target JS parse budget: <100ms on mid-range mobile
- Image budget per page: <500KB total (lazy loaded)

CDN edge nodes:          200+ global PoPs
Cache hit rate target:   85% for suggestions, 40% for results
💡

The suggestion service alone handles 4-6× the QPS of the actual search API. This means the typeahead infrastructure must be optimized independently — smaller payloads, aggressive edge caching, and client-side caching of recent prefixes.


Architecture / High-Level Design

Rendering Strategy

Streaming SSR with progressive hydration. The SERP uses a streaming server response:

  1. HTML shell + critical CSS streams immediately (TTFB ≤100ms)
  2. Search input + suggestion JS hydrates first (interactive in ≤200ms)
  3. Result cards stream as backend services respond (chunked transfer encoding)
  4. Knowledge panel, PAA, and image carousels hydrate on visibility (Intersection Observer)

This is NOT a traditional SPA. Each search is a full navigation (for cacheability and SEO), but the search input maintains client-side state across navigations via beforeunload preservation.

URL Schema:
/search?q={query}&tbm={type}&p={page}&tbs={time_filter}&safe={0|1}&hl={lang}

Examples:
/search?q=react+fiber+architecture
/search?q=cats&tbm=isch&safe=1
/search?q=latest+news&tbm=nws&tbs=qdr:h
/search?q=restaurants&tbm=lcl&near=sf

Every state change updates the URL. Back/forward navigation restores exact SERP state. No client-side routing for result navigation — clicking a result is a standard <a href> for maximum performance and reliability.

System Architecture Diagram ASCII

Loading diagram...

Component Architecture

SearchPage
├── SearchHeader
│   ├── Logo
│   ├── SearchInput
│   │   ├── InputField (controlled)
│   │   ├── VoiceSearchButton
│   │   ├── CameraSearchButton
│   │   └── SuggestionDropdown
│   │       ├── SuggestionItem (keyboard navigable)
│   │       ├── RecentSearchItem
│   │       └── TrendingSearchItem
│   └── UserMenu
├── SearchFilters
│   ├── TypeTabs (All | Images | News | Videos | Maps | Shopping)
│   ├── TimeFilter
│   └── RegionFilter
├── ResultsContainer (streaming boundary)
│   ├── FeaturedSnippet
│   ├── WebResultCard
│   │   ├── ResultTitle (with query highlighting)
│   │   ├── ResultURL (breadcrumb format)
│   │   ├── ResultSnippet (with bold matches)
│   │   └── SiteLinks (2x3 grid when available)
│   ├── ImageCarousel (horizontal scroll)
│   ├── VideoResultCard (thumbnail + duration)
│   ├── NewsResultCard (publisher + timestamp)
│   ├── PeopleAlsoAsk (accordion)
│   │   └── PAQItem (expandable, lazy-loads answer)
│   ├── MapResultCard (embedded mini-map)
│   └── ShoppingResultCard (price + rating + image)
├── KnowledgePanel (right sidebar on desktop)
│   ├── EntityHeader (image + title + description)
│   ├── FactsTable
│   ├── RelatedEntities (carousel)
│   └── SocialLinks
├── Pagination
│   ├── PageNumbers
│   └── NextButton
└── SearchFooter
    ├── RelatedSearches
    └── LocationInfo

State Management Strategy

The SERP has minimal client state because it's URL-driven:

// URL is the single source of truth
interface SearchState {
  query: string; // ?q=
  type: SearchType; // ?tbm=
  page: number; // ?p=
  timeRange: TimeRange; // ?tbs=
  safeSearch: boolean; // ?safe=
  language: string; // ?hl=
}

// Client-only ephemeral state (not URL-persisted)
interface EphemeralState {
  suggestionQuery: string; // Current typeahead input
  suggestions: Suggestion[]; // Dropdown items
  selectedSuggestionIndex: number; // Keyboard nav position
  isVoiceActive: boolean; // Microphone recording
  expandedPAQ: Set<string>; // Expanded accordion items
}

State split: URL params for shareable state, component-local state for UI interactions. No global store needed — each component reads from URL and manages its own ephemeral state.


Data Model / Entities

// Core search types
type SearchType = "web" | "images" | "news" | "videos" | "maps" | "shopping";
type TimeRange = "any" | "hour" | "day" | "week" | "month" | "year" | "custom";
type SafeSearch = "off" | "moderate" | "strict";

// Suggestion types
interface Suggestion {
  id: string;
  text: string;
  type: "query" | "calculator" | "entity" | "navigation";
  icon?: string;
  entityImage?: string; // For entity suggestions (people, places)
  estimatedResults?: number;
}

interface RecentSearch {
  query: string;
  timestamp: number;
  type: SearchType;
}

// Federated result union
type SearchResult =
  | WebResult
  | ImageResult
  | NewsResult
  | VideoResult
  | MapResult
  | ShoppingResult
  | FeaturedSnippetResult
  | PeopleAlsoAskResult
  | KnowledgePanelResult;

// Web result (organic link)
interface WebResult {
  kind: "web";
  id: string;
  position: number;
  title: string; // With <em> tags for query match
  url: string;
  displayUrl: string; // Breadcrumb format: example.com › path › page
  snippet: string; // With <em> tags for query match highlighting
  datePublished?: string;
  siteLinks?: SiteLink[]; // Up to 6 sub-links
  favicon: string;
  schema?: StructuredData; // Recipe, FAQ, HowTo, etc.
  trackingId: string; // For CTR measurement
}

interface SiteLink {
  title: string;
  url: string;
  snippet?: string;
}

// Image result
interface ImageResult {
  kind: "image";
  id: string;
  thumbnailUrl: string;
  fullUrl: string;
  width: number;
  height: number;
  alt: string;
  sourceUrl: string;
  sourceDomain: string;
  title: string;
  aspectRatio: number; // For masonry layout calculation
}

// News result
interface NewsResult {
  kind: "news";
  id: string;
  title: string;
  url: string;
  source: string;
  sourceIcon: string;
  publishedAt: string; // ISO 8601
  thumbnailUrl?: string;
  snippet: string;
  isTopStory: boolean;
}

// Video result
interface VideoResult {
  kind: "video";
  id: string;
  title: string;
  url: string;
  thumbnailUrl: string;
  duration: number; // Seconds
  channel: string;
  publishedAt: string;
  views: number;
  platform: "youtube" | "vimeo" | "other";
}

// Map/Local result
interface MapResult {
  kind: "map";
  id: string;
  name: string;
  address: string;
  rating: number; // 1-5
  reviewCount: number;
  category: string;
  hours: string;
  distance?: string;
  lat: number;
  lng: number;
  photoUrl?: string;
}

// Shopping result
interface ShoppingResult {
  kind: "shopping";
  id: string;
  title: string;
  price: number;
  currency: string;
  merchant: string;
  imageUrl: string;
  rating?: number;
  reviewCount?: number;
  url: string;
  freeShipping: boolean;
}

// Featured snippet (position zero)
interface FeaturedSnippetResult {
  kind: "featured_snippet";
  id: string;
  type: "paragraph" | "list" | "table";
  content: string; // HTML content
  sourceTitle: string;
  sourceUrl: string;
  sourceDisplayUrl: string;
}

// People Also Ask
interface PeopleAlsoAskResult {
  kind: "people_also_ask";
  id: string;
  questions: PAQQuestion[];
}

interface PAQQuestion {
  id: string;
  question: string;
  answer?: string; // Lazy loaded on expand
  sourceUrl?: string;
  sourceTitle?: string;
}

// Knowledge panel
interface KnowledgePanelResult {
  kind: "knowledge_panel";
  id: string;
  title: string;
  subtitle: string;
  description: string;
  imageUrl: string;
  facts: Record<string, string>; // Key-value pairs
  socialLinks: { platform: string; url: string }[];
  relatedEntities: { name: string; imageUrl: string; url: string }[];
}

// Full search response (streamed)
interface SearchResponse {
  query: string;
  totalResults: number;
  correctedQuery?: string; // "Did you mean..."
  results: SearchResult[];
  knowledgePanel?: KnowledgePanelResult;
  relatedSearches: string[];
  filters: AvailableFilter[];
  timing: {
    totalMs: number;
    serviceTimings: Record<string, number>;
  };
}

// Click tracking
interface ClickEvent {
  queryId: string;
  resultId: string;
  position: number;
  resultType: SearchResult["kind"];
  timestamp: number;
  dwellTimeMs?: number; // Time before user returns to SERP
}

Interface Definition (API)

Search API

// GET /api/search?q=react+hooks&tbm=web&p=1&tbs=qdr:w&safe=1&hl=en
// Response: Streamed JSON (NDJSON) for progressive rendering

interface SearchRequest {
  q: string;
  tbm?: SearchType;
  p?: number; // Page (1-indexed)
  tbs?: string; // Time filter
  safe?: "0" | "1";
  hl?: string; // Language
  num?: number; // Results per page (default 10)
}

// Response streams as newline-delimited JSON:
// {"type":"meta","totalResults":1230000,"correctedQuery":null,"timing":{"totalMs":180}}
// {"type":"featured_snippet","data":{...}}
// {"type":"web","data":{...}}
// {"type":"web","data":{...}}
// {"type":"people_also_ask","data":{...}}
// {"type":"web","data":{...}}
// {"type":"related_searches","data":["react hooks tutorial","react hooks vs classes"]}
// {"type":"knowledge_panel","data":{...}}
// {"type":"done"}

Suggestion API

// GET /api/suggest?q=react+ho&type=web&hl=en
// Response: JSON (not streamed — must be <50ms)

interface SuggestionRequest {
  q: string; // Partial query
  type?: SearchType;
  hl?: string;
}

interface SuggestionResponse {
  suggestions: Suggestion[];
  recentSearches: RecentSearch[]; // Only if user is authenticated
}

Click Tracking API

// POST /api/track/click (beacon API — fire and forget)
interface ClickTrackRequest {
  queryId: string;
  resultId: string;
  position: number;
  resultType: string;
  action: "click" | "return" | "long_click";
  dwellTimeMs?: number;
}

People Also Ask Expand API

// GET /api/paq/expand?questionId=abc123&query=react+hooks
interface PAQExpandResponse {
  answer: string; // HTML content
  sourceUrl: string;
  sourceTitle: string;
  relatedQuestions: PAQQuestion[]; // Load more questions on expand
}

Caching Strategy

Client-Side Caching

// Suggestion cache — LRU with prefix matching
class SuggestionCache {
  private cache = new Map<string, { data: Suggestion[]; ts: number }>();
  private maxSize = 200;
  private ttlMs = 5 * 60 * 1000; // 5 minutes

  get(prefix: string): Suggestion[] | null {
    const entry = this.cache.get(prefix);
    if (!entry || Date.now() - entry.ts > this.ttlMs) return null;
    return entry.data;
  }

  // Also check if a longer cached prefix contains valid results
  getFromLongerPrefix(prefix: string): Suggestion[] | null {
    for (const [key, entry] of this.cache) {
      if (key.startsWith(prefix) && Date.now() - entry.ts < this.ttlMs) {
        return entry.data.filter((s) =>
          s.text.toLowerCase().startsWith(prefix.toLowerCase()),
        );
      }
    }
    return null;
  }
}

// Search result cache — sessionStorage for back/forward
const RESULT_CACHE_KEY = "serp_cache";

function cacheSearchResults(url: string, html: string): void {
  try {
    const cache = JSON.parse(sessionStorage.getItem(RESULT_CACHE_KEY) || "{}");
    cache[url] = { html, ts: Date.now() };
    // Evict oldest if >20 entries
    const keys = Object.keys(cache);
    if (keys.length > 20) {
      const oldest = keys.sort((a, b) => cache[a].ts - cache[b].ts)[0];
      delete cache[oldest];
    }
    sessionStorage.setItem(RESULT_CACHE_KEY, JSON.stringify(cache));
  } catch {
    /* quota exceeded — silent fail */
  }
}

CDN & Edge Caching

ResourceCache-ControlCDN TTLNotes
Suggestion APIpublic, max-age=3005 minPopular prefixes cached at edge
Search results (web)private, no-storePersonalized, never cached at CDN
Image thumbnailspublic, max-age=8640024hImmutable content-addressed URLs
Static assets (JS/CSS)public, max-age=31536000, immutable1 yearHashed filenames
Knowledge panel datapublic, max-age=3600, stale-while-revalidate=864001hEntity data changes slowly
Favicon spritespublic, max-age=6048007 daysAggregated favicon sheet

Cache Coherence

Suggestion cache invalidation:
├── Trending queries → TTL 5 min (new trends appear fast)
├── Entity suggestions → TTL 1 hour (stable)
├── Calculator/unit results → TTL 24 hours (immutable)
└── Personalized suggestions → No CDN cache, client LRU only

Search results:
├── Never CDN-cached (personalization, freshness, legal)
├── bfcache (Back/Forward Cache) for instant back navigation
├── sessionStorage snapshot for client-side back/forward
└── Service Worker: cache suggestion API + static assets only
💡

Never cache full search results at the CDN layer. Search results are personalized (location, history, A/B cohort), time-sensitive (news), and legally sensitive (DMCA takedowns must propagate instantly). The only valid client cache is the browser's bfcache and sessionStorage for back/forward navigation.


Rendering & Performance Deep Dive

Critical Rendering Path

Timeline (target: first result visible in 400ms):

0ms    → Request sent
50ms   → Edge terminates TLS, routes to nearest search node
80ms   → Server begins streaming HTML shell + critical CSS (inlined)
100ms  → Browser receives first chunk: <head> + search input HTML
150ms  → Critical JS loaded (search input hydration: 12KB)
180ms  → Search input is interactive (can type new query)
200ms  → First result card HTML streams in (chunked transfer)
250ms  → Result cards render (zero JS needed — pure HTML + CSS)
400ms  → All 10 results rendered, LCP fires on first result title
600ms  → Knowledge panel streams in (right rail)
800ms  → Non-critical JS loads (click tracking, PAA, voice search)
1000ms → Full page interactive (all features hydrated)

Progressive Rendering

// Server: Stream results as NDJSON, rendered to HTML chunks
async function streamSearchResults(req: Request): Promise<Response> {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      // 1. Send HTML shell immediately
      controller.enqueue(encoder.encode(renderShell(req.query)));

      // 2. Start federated search (parallel backend calls)
      const resultStream = searchOrchestrator.stream(req.query);

      // 3. Stream each result as an HTML chunk
      for await (const result of resultStream) {
        const html = renderResultCard(result);
        controller.enqueue(encoder.encode(html));
      }

      // 4. Send closing HTML + deferred scripts
      controller.enqueue(encoder.encode(renderFooter()));
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/html; charset=utf-8",
      "Transfer-Encoding": "chunked",
    },
  });
}

Core Web Vitals

MetricTargetStrategy
LCP≤1.2sFirst result card is server-rendered HTML, no JS dependency
INP≤100msSuggestion selection uses requestAnimationFrame batching
CLS0.00All result cards have fixed height slots via CSS contain: layout
FID≤50msCritical JS is 12KB (search input only); rest deferred
TTFB≤100msEdge streaming, no full-page server render blocking
TBT≤150msNo long tasks; hydration split into 5ms microtasks

CLS prevention strategy:

/* Reserve exact space for each result card type */
.result-card--web {
  min-height: 116px;
  contain: layout style;
}
.result-card--video {
  min-height: 138px;
  contain: layout style;
}
.result-card--news {
  min-height: 96px;
  contain: layout style;
}
.result-card--image-carousel {
  height: 180px;
  contain: strict;
}
.knowledge-panel {
  width: 360px;
  min-height: 400px;
  contain: layout;
}

/* Skeleton exactly matches final layout */
.result-skeleton {
  height: 116px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

Bundle Optimization

Total JS budget: 80KB gzipped (initial load)

Chunk split strategy:
├── critical.js (12KB) — Search input, form submission, URL state
├── suggestions.js (8KB) — Typeahead dropdown, keyboard nav
├── tracking.js (3KB) — Click beacon, visibility observer
├── voice.js (6KB) — Web Speech API wrapper (loaded on mic click)
├── paq.js (4KB) — People Also Ask accordion expand
├── images.js (15KB) — Masonry layout, lightbox (route: /images)
├── maps.js (20KB) — Map embed, local results (route: /maps)
└── vendor.js (12KB) — Shared utilities (debounce, intersection observer polyfill)

Loading strategy:
- critical.js: inline in HTML (no network request)
- suggestions.js: preload, execute on first keydown
- tracking.js: defer, execute after DOMContentLoaded
- voice.js: dynamic import on microphone button click
- images.js/maps.js: route-based code splitting

Security Deep Dive

Threat Model

ThreatVectorImpactMitigation
XSS via search resultsMalicious page title/snippet indexedExecute JS in SERP contextServer-side sanitization + CSP
Open redirectCrafted result URL via tracking redirectPhishingAllowlist domains, validate redirect targets
Query injectionReflected query in DOMXSSEncode query in all render contexts
ClickjackingEmbed SERP in iframeSteal clicksX-Frame-Options: DENY
CSRF on trackingForge click eventsPoison ranking dataSameSite cookies + request origin validation
Privacy leakageReferer header on result clickQuery exposed to third-partyReferrer-Policy: origin on outbound links
DOM clobberingNamed elements in snippet HTMLOverride globalsSanitize id and name attributes

XSS in Search Results

Search results contain user-influenced content (page titles, snippets, URLs). The rendering pipeline must sanitize at multiple levels:

// Server-side: sanitize indexed content before storage
function sanitizeSnippet(raw: string): string {
  // Allow only <em> for query highlighting
  const allowed = new Set(['em']);
  return raw.replace(/<\/?([a-z]+)[^>]*>/gi, (match, tag) => {
    return allowed.has(tag.toLowerCase()) ? match : '';
  });
}

// Client-side: never use innerHTML for user content
function HighlightedSnippet({ snippet }: { snippet: string }) {
  // snippet contains only <em> tags (server-sanitized)
  // Use a restricted parser, not dangerouslySetInnerHTML
  const parts = parseHighlightedText(snippet); // splits on <em> boundaries
  return (
    <p className="snippet">
      {parts.map((part, i) =>
        part.highlighted
          ? <em key={i} className="font-bold">{part.text}</em>
          : <span key={i}>{part.text}</span>
      )}
    </p>
  );
}

Open Redirect Prevention

Result links often pass through a tracking redirect (/url?q=...&url=TARGET). This is an open redirect vulnerability if not validated:

// Tracking redirect handler
function handleTrackingRedirect(req: Request): Response {
  const targetUrl = req.searchParams.get("url");
  const queryId = req.searchParams.get("qid");

  // 1. Validate URL format
  let parsed: URL;
  try {
    parsed = new URL(targetUrl);
  } catch {
    return new Response("Invalid URL", { status: 400 });
  }

  // 2. Block dangerous schemes
  const allowedSchemes = new Set(["http:", "https:"]);
  if (!allowedSchemes.has(parsed.protocol)) {
    return new Response("Blocked scheme", { status: 400 });
  }

  // 3. Block internal redirects (prevent SSRF via redirect)
  const blockedHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0"]);
  if (
    blockedHosts.has(parsed.hostname) ||
    parsed.hostname.endsWith(".internal")
  ) {
    return new Response("Blocked host", { status: 400 });
  }

  // 4. Log click asynchronously
  trackClick(queryId, targetUrl).catch(() => {});

  // 5. Redirect with safe headers
  return new Response(null, {
    status: 302,
    headers: {
      Location: targetUrl,
      "Referrer-Policy": "origin",
      "X-Robots-Tag": "noindex",
    },
  });
}

CSP

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{SERVER_NONCE}' https://cdn.search.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' https: data:;
  connect-src 'self' https://suggest.search.com https://track.search.com;
  frame-src 'self' https://maps.search.com;
  font-src 'self' https://fonts.gstatic.com;
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

img-src https: is required because result thumbnails come from arbitrary domains. All other resources are restricted to first-party origins.


Scalability & Reliability

Failure ModeDetectionImpactRecovery
Suggestion service downHealth check fails, timeout >200msNo typeahead, user can still submit queryGraceful degradation: hide dropdown, rely on client cache
Image index slowP95 >2sImage carousel missing from SERPShow skeleton for 3s, then hide carousel slot; serve web-only results
Knowledge graph timeout>500ms per entity lookupNo knowledge panelSkip panel entirely — SERP works without it
Streaming connection dropTCP RST mid-streamPartial results shownClient detects incomplete stream, shows "Load more" button
CDN edge failureOrigin health check, synthetic monitoringIncreased latencyDNS failover to secondary PoP within 30s
Spike in QPS (trending event)Auto-scaling trigger at 80% CPUDegraded latencyScale horizontally; shed non-critical features (PAA, related searches)
Database corruption (suggestion trie)Checksum mismatchStale suggestionsServe from replica; rebuild primary async
Client JS errorError boundary + SentryFeature-specific failureError boundary shows fallback; core SERP (HTML) still works

Graceful degradation priority:

Must work (no JS required):
├── Display search results (server-rendered HTML)
├── Pagination links
├── Filter tab links
└── Result link clicks

Should work (critical JS):
├── Search input with form submission
├── URL state management
└── Back/forward cache restoration

Nice to have (deferred JS):
├── Suggestion dropdown
├── Voice search
├── People Also Ask expand
├── Click tracking
├── Image masonry layout
└── Knowledge panel interactions
💡

A SERP must function as a pure HTML document. Every result is a standard <a href> link. Every filter is a <a href> to a new URL. The page is usable with JavaScript completely disabled. Progressive enhancement adds suggestions, voice search, and tracking — but the core product never depends on client JS.


Accessibility Deep Dive

Semantic Structure

<main role="main" aria-label="Search results for 'react hooks'">
  <h1 class="sr-only">Search results for "react hooks"</h1>

  <!-- Search input -->
  <search role="search" aria-label="Search">
    <input
      type="search"
      aria-label="Search query"
      aria-expanded="true"           <!-- suggestions visible -->
      aria-controls="suggestions-list"
      aria-activedescendant="suggestion-2"  <!-- keyboard highlighted -->
      aria-autocomplete="list"
      autocomplete="off"
    />
    <ul id="suggestions-list" role="listbox" aria-label="Search suggestions">
      <li id="suggestion-0" role="option" aria-selected="false">...</li>
      <li id="suggestion-1" role="option" aria-selected="false">...</li>
      <li id="suggestion-2" role="option" aria-selected="true">...</li>
    </ul>
  </search>

  <!-- Filter tabs -->
  <nav aria-label="Result type filters">
    <ul role="tablist">
      <li role="tab" aria-selected="true" aria-controls="results-panel">All</li>
      <li role="tab" aria-selected="false">Images</li>
      ...
    </ul>
  </nav>

  <!-- Results -->
  <section id="results-panel" role="tabpanel" aria-label="Web results">
    <ol aria-label="Search results" role="list">
      <li class="result-card" role="listitem">
        <article>
          <h2><a href="...">Result Title</a></h2>
          <cite>example.com › path</cite>
          <p>Snippet text with <em>highlighted</em> terms...</p>
        </article>
      </li>
    </ol>
  </section>

  <!-- Pagination -->
  <nav aria-label="Search result pages">
    <a href="?p=2" aria-label="Page 2">2</a>
    <a href="?p=2" aria-label="Next page">Next</a>
  </nav>
</main>

Keyboard Navigation

KeyContextAction
/ Suggestion dropdown openMove highlight between suggestions
EnterSuggestion highlightedSubmit highlighted suggestion as query
EscapeSuggestion dropdown openClose dropdown, restore original query
TabSearch input focusedMove focus to first result link
EnterResult link focusedNavigate to result
SpacePAQ question focusedExpand/collapse answer
/Anywhere on pageFocus search input (Gmail-style shortcut)

Screen Reader Announcements

// Announce result count after search
function announceResults(count: number, query: string): void {
  const announcer = document.getElementById("sr-announcer");
  announcer.textContent = `${count.toLocaleString()} results found for "${query}"`;
}

// Announce suggestion selection during keyboard nav
function announceSuggestion(text: string, index: number, total: number): void {
  const announcer = document.getElementById("sr-announcer");
  announcer.textContent = `${text}, suggestion ${index + 1} of ${total}`;
}
<div
  id="sr-announcer"
  aria-live="polite"
  aria-atomic="true"
  class="sr-only"
></div>

Monitoring & Observability

MetricAlert ThresholdDashboard
P50 Time to First Result>500msSERP Performance
P95 Time to First Result>1.2sSERP Performance
Suggestion P95 latency>150msTypeahead Health
LCP (field data, CrUX)>2.5sCore Web Vitals
CLS (field data)>0.05Core Web Vitals
INP (field data)>200msCore Web Vitals
Click-through rate (position 1)<20% (7-day avg)Quality/Relevance
Zero-result rate>5%Search Quality
Suggestion cache hit rate<70%Infrastructure
JS error rate>0.5% sessionsClient Health
Voice search success rate<80% recognitionVoice Feature
Streaming connection failures>1%Reliability
Accessibility violations (automated)Any new P1Compliance

Custom Performance Marks

// Instrument critical path
performance.mark("search-submit");

// First result painted (via MutationObserver)
const observer = new MutationObserver((mutations) => {
  if (document.querySelector(".result-card")) {
    performance.mark("first-result-painted");
    performance.measure(
      "time-to-first-result",
      "search-submit",
      "first-result-painted",
    );
    observer.disconnect();
    reportMetric(
      "ttfr",
      performance.getEntriesByName("time-to-first-result")[0].duration,
    );
  }
});
observer.observe(document.getElementById("results-container"), {
  childList: true,
});

// Track suggestion latency
async function fetchSuggestions(query: string): Promise<Suggestion[]> {
  const start = performance.now();
  const response = await fetch(`/api/suggest?q=${encodeURIComponent(query)}`);
  const latency = performance.now() - start;
  reportMetric("suggestion_latency", latency, { prefix_length: query.length });
  return response.json();
}

Click Tracking & A/B Testing

// Track result clicks with position and dwell time
function trackResultClick(result: WebResult, queryId: string): void {
  const clickTime = Date.now();

  // Use Beacon API for reliable delivery (even on page unload)
  navigator.sendBeacon(
    "/api/track/click",
    JSON.stringify({
      queryId,
      resultId: result.trackingId,
      position: result.position,
      resultType: result.kind,
      action: "click",
      abCohort: getABCohort(), // Which layout variant
      timestamp: clickTime,
    }),
  );

  // Track dwell time when user returns (via Page Visibility API)
  document.addEventListener("visibilitychange", function handler() {
    if (document.visibilityState === "visible") {
      const dwellTime = Date.now() - clickTime;
      navigator.sendBeacon(
        "/api/track/click",
        JSON.stringify({
          queryId,
          resultId: result.trackingId,
          action: dwellTime > 30000 ? "long_click" : "return",
          dwellTimeMs: dwellTime,
        }),
      );
      document.removeEventListener("visibilitychange", handler);
    }
  });
}

Trade-offs

DecisionChoseOverRationale
Rendering modelStreaming SSRFull client SPA400ms first result vs 1200ms+ SPA cold start; SERP is read-heavy, not interaction-heavy
Pagination styleNumbered pages (web), Infinite scroll (images)Universal approachWeb results need shareable URLs per page; image grids benefit from continuous browsing
Suggestion transportHTTP GET with CDN cachingWebSocketWebSocket adds connection overhead for a stateless req/res pattern; CDN cache hits reduce P50 to <10ms
State managementURL paramsClient store (Redux/Zustand)URL is the canonical state; enables sharing, bookmarking, back/forward, and eliminates hydration mismatch
Result card heightsFixed minimum heights (CSS contain)Dynamic auto-sizingPrevents CLS; trades slight whitespace for layout stability
Click trackingBeacon API (fire-and-forget)Tracking redirect (302)Avoids adding latency to result navigation; beacon is reliable and non-blocking
Image layoutCSS Grid masonry (future) / JS masonry (today)Fixed gridMasonry maximizes viewport utilization for variable-aspect images; JS fallback until CSS masonry ships
JS loadingInline critical (12KB) + deferred restSingle bundleShaves 200ms off interactivity for search input; rest loads without blocking
Knowledge panelStreamed after main resultsBlocking (wait for all)Main results visible 200ms earlier; panel appears without layout shift in reserved sidebar
SanitizationServer-side allowlist (only <em>)Client-side DOMPurifyEliminates entire class of XSS; smaller client bundle; security at the data layer

What Great Looks Like

Senior Engineer (L5)

  • Implements streaming SSR with proper chunked encoding and skeleton states
  • Builds suggestion dropdown with debounce, keyboard nav, and LRU cache
  • URL-driven state with proper encoding/decoding and History API integration
  • Result cards render correctly for all federated types
  • Core Web Vitals within targets
  • Proper semantic HTML and ARIA for accessibility
  • Click tracking with Beacon API

Staff Engineer (L6)

  • All of Senior, plus:
  • Designs the progressive hydration strategy — identifies exactly which bytes must block and which can defer
  • Implements graceful degradation: the page works as pure HTML with zero JS
  • Builds the suggestion cache with prefix matching and stale-while-revalidate patterns
  • Designs the CLS prevention system (reserved heights, CSS containment)
  • Architects the A/B testing framework for result layouts
  • Implements the security model: sanitization pipeline, CSP, open redirect prevention
  • Defines the monitoring strategy with custom performance marks and alert thresholds

Principal Engineer (L7)

  • All of Staff, plus:
  • Designs the federated search orchestration: timeout budgets per backend, priority ordering, partial result delivery
  • Architects the suggestion infrastructure at scale: trie data structure, edge caching topology, personalization without latency penalty
  • Defines the rendering architecture decision (streaming SSR vs Islands vs RSC) with measured trade-offs and migration path
  • Builds the observability platform: correlates click signals with search quality metrics, drives ranking improvements from frontend data
  • Designs the system for 500K concurrent users with graceful load shedding
  • Owns the cross-team contract between frontend, search ranking, and infrastructure teams

Key Takeaways

  • Streaming SSR is non-negotiable for SERP — users see first results in 400ms, not after a full SPA bootstrap. Every millisecond of delay measurably reduces engagement.
  • URL is the state store — query, page, filters, and type all live in the URL. This gives you shareability, bookmarking, back/forward, and server-side rendering for free. No client state management framework needed.
  • Progressive enhancement is mandatory — the SERP must function as pure HTML with <a> links. JavaScript adds typeahead, voice, tracking, and animations but never gates core functionality.
  • CLS zero requires upfront engineering — reserve exact pixel heights for every result card type using CSS contain. Layout shifts on a SERP are catastrophic for usability and brand trust.
  • Suggestion latency ≤100ms demands a separate infrastructure — edge caching, client LRU, prefix matching, and smaller payloads than the main search API. The suggestion service runs at 5× the QPS of search itself.
  • Security surface is large — result snippets, URLs, and tracking redirects are all XSS/open-redirect vectors. Sanitize at the data layer (server-side allowlist), not the render layer. CSP is a safety net, not the primary defense.
  • Click tracking feeds relevance — CTR by position, dwell time, and pogo-sticking signals directly improve search quality. Use Beacon API for non-blocking, reliable delivery.
  • Graceful degradation is the reliability strategy — when image search is slow, hide the carousel. When knowledge graph times out, skip the panel. The SERP never shows an error page; it shows fewer features.
💡

The meta-lesson: a SERP is optimized for time-to-information, not time-to-interactive. Users want to see results and click a link — most sessions have zero meaningful interaction with the page beyond reading and clicking. Design everything around that single flow: arrive → scan → click → leave.