System Design
Part 2 of 5Airbnb Frontend System Architecture: Performance-First Design at Global Scale
Airbnb Frontend System Architecture: Performance-First Design at Global Scale
1. Product Overview & Scale
Airbnb operates one of the most visually-rich web applications on the internet—a marketplace connecting 5+ million hosts with 150+ million users across 220+ countries. The frontend must render high-resolution photography, interactive maps, complex date pickers, and real-time pricing while maintaining sub-second perceived performance.
Scale Metrics:
- 7+ million active listings with 100+ images each
- 2+ billion guest arrivals since inception
- 100+ million monthly active users
- 62 supported languages
- 220+ countries and regions
- 40+ currencies with real-time conversion
- Peak traffic: 10x normal during major holidays
The frontend challenge isn't just about rendering—it's about rendering fast while maintaining the emotional, trust-building visual experience that drives booking conversion. A 100ms delay in LCP correlates directly with booking abandonment.
┌─────────────────────────────────────────────────────────────────────┐
│ AIRBNB TRAFFIC PATTERNS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Requests/sec │
│ │ ┌──┐ │
│ 50k │ ┌┤ ├┐ Holiday │
│ │ ┌┤│ ││├┐ Peaks │
│ 40k │ ┌┤││ │││├┐ │
│ │ ┌┤│││ ││││├┐ │
│ 30k │ Weekend ┌──┐ Weekend ┌┤││││ │││││├┐ │
│ │ Peaks ┌┤ ├┐ Peaks ┌┤│││││ ││││││├┐ │
│ 20k │ ┌──┐ ┌──┐ ┌┤│ ││├┐ ┌──┐ ┌──┐ ┌┤││││││ │││││││├┐ │
│ │ ┌┤ ├──┤ ├─┤││ ││││──┤ ├──┤ ├─┤│││││││ ││││││││├─ │
│ 10k │─┤│ │ │ │ │││ ││││ │ │ │ │ ││││││││ │││││││││ │── │
│ │ ││ │ │ │ │││ ││││ │ │ │ │ ││││││││ │││││││││ │ │
│ 0 └─┴┴──┴──┴──┴─┴┴┴──┴┴┴┴──┴──┴──┴──┴─┴┴┴┴┴┴┴┴──┴┴┴┴┴┴┴┴┴─┴──────│
│ Mon Tue Wed Thu Fri Sat Sun Mon ... Dec 20-Jan 5 │
│ │
│ Critical Insight: 3x traffic variance requires elastic rendering │
└─────────────────────────────────────────────────────────────────────┘
2. Core Frontend Challenges
The Image-Heavy Performance Paradox
Airbnb's core value proposition is visual—users browse properties through photographs. Yet images are the single largest contributor to poor Core Web Vitals:
┌─────────────────────────────────────────────────────────────────────┐
│ PAGE WEIGHT BREAKDOWN (SEARCH RESULTS) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Images ████████████████████████████████████████ 78% │
│ JavaScript ████████ 16% │
│ CSS ██ 3% │
│ Fonts █ 2% │
│ HTML ░ 1% │
│ │
│ Total: ~3.2MB (unoptimized) → 890KB (optimized) │
│ │
│ Challenge: 20+ listing cards × 5 images each = 100+ images │
│ Must load above-fold in <2.5s for good LCP │
└─────────────────────────────────────────────────────────────────────┘
The Six Core Challenges
| Challenge | Impact | Complexity |
|---|---|---|
| Image Loading | LCP directly tied to hero image render | High - need blur placeholders, AVIF/WebP, responsive srcset |
| Map Integration | 500KB+ JS payload, blocks main thread | Critical - MapBox/Google Maps lazy loading, clustering |
| Date Picker | Complex calendar logic, availability checks | Medium - virtual scrolling, memoization |
| Real-time Pricing | Price changes based on dates/guests | High - optimistic UI, streaming updates |
| Search Filters | 50+ filter combinations, URL state sync | High - debouncing, URL serialization |
| Layout Stability | Images, maps, dynamic content cause shifts | Critical - skeleton screens, aspect ratios |
The Trust-Performance Balance
Unlike e-commerce where users trust the brand, Airbnb users must trust strangers' homes. High-quality imagery isn't optional—it's the foundation of the business model. This creates a fundamental tension:
// The core tension in Airbnb's frontend architecture
interface PerformanceTrustTradeoff {
// High-res images build trust but hurt LCP
imageQuality: 'high' | 'medium' | 'low';
// More images per listing increase confidence but hurt TTI
imagesPerCard: number; // Optimal: 5-7, Users want: 20+
// Instant map interaction builds confidence but blocks main thread
mapInteractivity: 'immediate' | 'deferred' | 'on-demand';
// Reviews visible immediately vs lazy-loaded
reviewsStrategy: 'ssr' | 'lazy' | 'intersection-observer';
}
// Airbnb's solution: Progressive enhancement with perceived performance
const OPTIMAL_CONFIG: PerformanceTrustTradeoff = {
imageQuality: 'high', // Never compromise
imagesPerCard: 1, // Show 1 fast, carousel on hover
mapInteractivity: 'deferred', // 100ms delay after FCP
reviewsStrategy: 'intersection-observer'
};
3. High-Level Frontend Architecture
Airbnb pioneered several patterns now considered industry standard, including the adoption of Server Components and their influential "Design Language System" (DLS).
┌─────────────────────────────────────────────────────────────────────────────┐
│ AIRBNB FRONTEND ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CDN EDGE LAYER │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ Fastly │ │ Image │ │ Static │ │ HTML │ │ │
│ │ │ (Primary) │ │ CDN │ │ Assets │ │ Cache │ │ │
│ │ │ │ │ (Thumbor) │ │ (Hashed) │ │ (5min) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ RENDERING LAYER (Node.js) │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ Hypernova │ │ React Server │ │ Streaming SSR │ │ │
│ │ │ (Legacy Pages) │ │ Components │ │ (Critical Path) │ │ │
│ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ REQUEST COALESCING & DEDUPLICATION │ │ │
│ │ │ • Batch GraphQL queries across components │ │ │
│ │ │ • Dedupe identical requests within 50ms window │ │ │
│ │ │ • Priority queue: above-fold data first │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CLIENT APPLICATION │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ React 18 │ │ Design │ │ State │ │ Route │ │ │
│ │ │ + RSC │ │ Language │ │ (Apollo │ │ Based │ │ │
│ │ │ │ │ System │ │ + Zustand)│ │ Splitting│ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ PERFORMANCE LAYER │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │
│ │ │ │ Priority │ │ Idle │ │ Resource │ │ Prefetch │ │ │ │
│ │ │ │ Scheduler│ │ Until │ │ Hints │ │ Predictor │ │ │ │
│ │ │ │ │ │ Urgent │ │ │ │ │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Niobe Framework
Airbnb built "Niobe," an internal framework that extends React with performance primitives:
// Niobe: Airbnb's performance-first React framework
import {
createPage,
PriorityBoundary,
DeferredHydration,
ResourceScheduler
} from '@airbnb/niobe';
// Page definition with explicit performance budgets
export default createPage({
name: 'SearchResults',
// Performance budgets enforced at build time
budgets: {
jsBundle: 150_000, // 150KB max
cssBundle: 30_000, // 30KB max
lcpTarget: 2500, // 2.5s
inpTarget: 200, // 200ms
clsTarget: 0.1, // 0.1
},
// Critical data fetched server-side
getServerData: async (context) => {
const { searchParams } = context;
// Parallel fetch with timeout
const [listings, mapData] = await Promise.all([
fetchListings(searchParams).timeout(800),
fetchMapBounds(searchParams).timeout(400),
]);
return { listings, mapData };
},
// Client-side data (non-blocking)
getClientData: async (context) => {
// Deferred until after hydration
return {
recommendations: fetchRecommendations(context.userId),
recentSearches: fetchRecentSearches(context.userId),
};
},
});
4. Core Web Vitals Deep Dive
LCP Optimization Strategy
Airbnb's LCP element is almost always the hero listing image. Their optimization strategy is multi-layered:
┌─────────────────────────────────────────────────────────────────────────────┐
│ LCP OPTIMIZATION PIPELINE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ REQUEST │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. EDGE OPTIMIZATION (0-50ms) │ │
│ │ • HTML cached at edge with 5-min TTL │ │
│ │ • Critical CSS inlined (<14KB) │ │
│ │ • <link rel="preload"> for LCP image injected at edge │ │
│ │ • Early hints (103) for critical resources │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. HTML STREAMING (50-200ms) │ │
│ │ • Stream <head> immediately with resource hints │ │
│ │ • Stream above-fold HTML before data resolves │ │
│ │ • Skeleton placeholders with correct aspect ratios │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. IMAGE PIPELINE (200-800ms) │ │
│ │ • BlurHash placeholder rendered immediately (< 1KB) │ │
│ │ • AVIF served to supported browsers (40% smaller) │ │
│ │ • Responsive srcset with density descriptors │ │
│ │ • fetchpriority="high" on LCP image │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. RENDER OPTIMIZATION (800-1500ms) │ │
│ │ • content-visibility: auto on below-fold content │ │
│ │ • Intersection Observer for lazy images │ │
│ │ • will-change hints for animated elements │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ LCP TARGET: < 2.5s (p75) │
│ ACTUAL: 1.8s (desktop), 2.3s (mobile 4G) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// LCP-optimized image component
import { BlurHash } from '@airbnb/blur-hash';
interface OptimizedImageProps {
src: string;
blurHash: string;
alt: string;
width: number;
height: number;
priority?: boolean;
sizes?: string;
}
export function OptimizedImage({
src,
blurHash,
alt,
width,
height,
priority = false,
sizes = '100vw',
}: OptimizedImageProps) {
const [loaded, setLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
// Generate responsive srcset
const srcSet = useMemo(() => {
const widths = [320, 640, 960, 1280, 1920, 2560];
return widths
.filter(w => w <= width * 2) // Don't upscale beyond 2x
.map(w => `${generateImageUrl(src, w)} ${w}w`)
.join(', ');
}, [src, width]);
// Format selection based on browser support
const format = useMemo(() => {
if (supportsAvif()) return 'avif';
if (supportsWebp()) return 'webp';
return 'jpg';
}, []);
return (
<div
className="image-container"
style={{
aspectRatio: `${width} / ${height}`,
// Prevent CLS by reserving exact space
width: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
{/* BlurHash placeholder - renders instantly */}
<BlurHash
hash={blurHash}
width={width}
height={height}
punch={1}
style={{
position: 'absolute',
inset: 0,
opacity: loaded ? 0 : 1,
transition: 'opacity 300ms ease-out',
}}
/>
{/* Actual image with all optimizations */}
<img
ref={imgRef}
src={generateImageUrl(src, 960, format)}
srcSet={srcSet}
sizes={sizes}
alt={alt}
width={width}
height={height}
loading={priority ? 'eager' : 'lazy'}
decoding={priority ? 'sync' : 'async'}
fetchPriority={priority ? 'high' : 'auto'}
onLoad={() => setLoaded(true)}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: loaded ? 1 : 0,
transition: 'opacity 300ms ease-out',
}}
/>
</div>
);
}
// Image URL generator with CDN transformations
function generateImageUrl(
src: string,
width: number,
format: 'avif' | 'webp' | 'jpg' = 'webp'
): string {
const params = new URLSearchParams({
w: width.toString(),
f: format,
q: format === 'avif' ? '60' : '80', // AVIF needs lower quality number
fit: 'crop',
auto: 'compress',
});
return `https://a0.muscache.com/im/pictures/${src}?${params}`;
}
INP (Interaction to Next Paint) Optimization
Airbnb's search page has numerous interactive elements—filters, map interactions, carousels, date pickers. Each must respond within 200ms:
// INP-optimized interaction handlers
import {
startTransition,
useDeferredValue,
useTransition
} from 'react';
// Priority scheduler for interactions
class InteractionScheduler {
private highPriorityQueue: Array<() => void> = [];
private lowPriorityQueue: Array<() => void> = [];
private isProcessing = false;
// High priority: visual feedback (< 50ms)
scheduleHighPriority(callback: () => void) {
this.highPriorityQueue.push(callback);
this.process();
}
// Low priority: data updates (< 200ms)
scheduleLowPriority(callback: () => void) {
this.lowPriorityQueue.push(callback);
this.process();
}
private process() {
if (this.isProcessing) return;
this.isProcessing = true;
// Use scheduler API if available, else requestIdleCallback
if ('scheduler' in globalThis) {
this.processWithScheduler();
} else {
this.processWithRIC();
}
}
private async processWithScheduler() {
// Process high priority immediately
while (this.highPriorityQueue.length > 0) {
const task = this.highPriorityQueue.shift()!;
await scheduler.postTask(task, { priority: 'user-blocking' });
}
// Process low priority when idle
while (this.lowPriorityQueue.length > 0) {
const task = this.lowPriorityQueue.shift()!;
await scheduler.postTask(task, { priority: 'background' });
}
this.isProcessing = false;
}
private processWithRIC() {
// High priority in next microtask
queueMicrotask(() => {
while (this.highPriorityQueue.length > 0) {
this.highPriorityQueue.shift()!();
}
});
// Low priority in idle callback
requestIdleCallback((deadline) => {
while (
this.lowPriorityQueue.length > 0 &&
deadline.timeRemaining() > 5
) {
this.lowPriorityQueue.shift()!();
}
if (this.lowPriorityQueue.length > 0) {
this.processWithRIC(); // Continue in next idle period
} else {
this.isProcessing = false;
}
});
}
}
const scheduler = new InteractionScheduler();
// Filter component with optimized INP
export function SearchFilters({
filters,
onFilterChange
}: SearchFiltersProps) {
const [isPending, startTransition] = useTransition();
const [localFilters, setLocalFilters] = useState(filters);
// Deferred value for expensive renders
const deferredFilters = useDeferredValue(localFilters);
const handleFilterChange = useCallback((
key: string,
value: FilterValue
) => {
// Immediate visual feedback (high priority)
scheduler.scheduleHighPriority(() => {
setLocalFilters(prev => ({ ...prev, [key]: value }));
});
// Actual data fetch (low priority, wrapped in transition)
scheduler.scheduleLowPriority(() => {
startTransition(() => {
onFilterChange(key, value);
});
});
}, [onFilterChange]);
return (
<div
className="filters-container"
style={{ opacity: isPending ? 0.7 : 1 }}
>
<PriceRangeFilter
value={localFilters.priceRange}
onChange={(v) => handleFilterChange('priceRange', v)}
/>
<AmenitiesFilter
value={localFilters.amenities}
onChange={(v) => handleFilterChange('amenities', v)}
/>
<PropertyTypeFilter
value={localFilters.propertyType}
onChange={(v) => handleFilterChange('propertyType', v)}
/>
{/* ... more filters */}
</div>
);
}
CLS (Cumulative Layout Shift) Prevention
Layout shifts are particularly problematic on Airbnb due to dynamic content—images loading, maps initializing, prices updating:
// CLS prevention system
import { create } from 'zustand';
// Track all potential layout shift sources
interface LayoutStabilityState {
reservedSpaces: Map<string, { width: number; height: number }>;
loadedElements: Set<string>;
reserveSpace: (id: string, dimensions: { width: number; height: number }) => void;
markLoaded: (id: string) => void;
isStable: (id: string) => boolean;
}
const useLayoutStability = create<LayoutStabilityState>((set, get) => ({
reservedSpaces: new Map(),
loadedElements: new Set(),
reserveSpace: (id, dimensions) => {
set(state => ({
reservedSpaces: new Map(state.reservedSpaces).set(id, dimensions),
}));
},
markLoaded: (id) => {
set(state => ({
loadedElements: new Set(state.loadedElements).add(id),
}));
},
isStable: (id) => get().loadedElements.has(id),
}));
// Stable container component
interface StableContainerProps {
id: string;
aspectRatio?: number;
minHeight?: number;
children: React.ReactNode;
onLoad?: () => void;
}
export function StableContainer({
id,
aspectRatio,
minHeight,
children,
onLoad,
}: StableContainerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { reserveSpace, markLoaded, isStable } = useLayoutStability();
// Reserve space on mount
useLayoutEffect(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
reserveSpace(id, { width: rect.width, height: rect.height });
}, [id, reserveSpace]);
// Handle content load
const handleContentLoad = useCallback(() => {
markLoaded(id);
onLoad?.();
}, [id, markLoaded, onLoad]);
return (
<div
ref={containerRef}
className="stable-container"
style={{
// Prevent CLS with explicit dimensions
aspectRatio: aspectRatio ? `${aspectRatio}` : undefined,
minHeight: minHeight ? `${minHeight}px` : undefined,
// Contain layout to prevent shifts affecting siblings
contain: 'layout paint',
// Reserve space even before content loads
contentVisibility: isStable(id) ? 'visible' : 'auto',
}}
onLoad={handleContentLoad}
>
{children}
</div>
);
}
// Listing card with CLS prevention
export function ListingCard({ listing }: ListingCardProps) {
return (
<article className="listing-card">
{/* Image with reserved aspect ratio */}
<StableContainer
id={`listing-image-${listing.id}`}
aspectRatio={4 / 3}
>
<OptimizedImage
src={listing.photos[0].url}
blurHash={listing.photos[0].blurHash}
alt={listing.title}
width={listing.photos[0].width}
height={listing.photos[0].height}
sizes="(max-width: 744px) 100vw, (max-width: 1128px) 50vw, 33vw"
/>
</StableContainer>
{/* Content with fixed height skeleton */}
<StableContainer
id={`listing-content-${listing.id}`}
minHeight={120}
>
<div className="listing-info">
<h3>{listing.title}</h3>
<p>{listing.location}</p>
<PriceDisplay
price={listing.price}
currency={listing.currency}
/>
</div>
</StableContainer>
</article>
);
}
/* CSS strategies for CLS prevention */
/* Reserve space for images before load */
.listing-card img {
aspect-ratio: 4 / 3;
width: 100%;
height: auto;
object-fit: cover;
background: var(--skeleton-color);
}
/* Prevent font-swap CLS */
@font-face {
font-family: 'Airbnb Cereal';
src: url('/fonts/airbnb-cereal.woff2') format('woff2');
font-display: optional; /* Invisible period, no swap flash */
size-adjust: 100.6%; /* Match fallback metrics */
ascent-override: 95%;
descent-override: 25%;
line-gap-override: 0%;
}
/* Fallback with matched metrics */
.text-content {
font-family: 'Airbnb Cereal',
/* Fallback stack with similar metrics */
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
/* Map container with fixed dimensions */
.map-container {
aspect-ratio: 16 / 9;
min-height: 400px;
contain: strict;
content-visibility: auto;
contain-intrinsic-size: auto 400px;
}
/* Price skeleton matching final size */
.price-skeleton {
width: 80px;
height: 24px;
border-radius: 4px;
background: linear-gradient(
90deg,
var(--skeleton-color) 25%,
var(--skeleton-highlight) 50%,
var(--skeleton-color) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
5. Rendering Strategy & Hydration Architecture
Streaming SSR with Selective Hydration
Airbnb uses streaming SSR to get content to users faster, combined with selective hydration to prioritize interactive elements:
// Streaming SSR configuration
import { renderToPipeableStream } from 'react-dom/server';
import { createFromNodeStream } from 'react-server-dom-webpack/client';
interface StreamingRenderOptions {
// Timeout before falling back to client render
shellTimeout: number;
// Elements to prioritize for hydration
hydrationPriority: string[];
// Progressive enhancement config
progressiveEnhancement: {
// Core content that must work without JS
noJSFallback: boolean;
// Features that enhance with JS
enhancedFeatures: string[];
};
}
export async function renderPage(
request: Request,
options: StreamingRenderOptions
): Promise<Response> {
const url = new URL(request.url);
// Start data fetching immediately (don't wait for render)
const dataPromise = fetchPageData(url);
// Create abort controller for timeout
const abortController = new AbortController();
const timeoutId = setTimeout(
() => abortController.abort(),
options.shellTimeout
);
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<App
url={url}
dataPromise={dataPromise}
hydrationPriority={options.hydrationPriority}
/>,
{
bootstrapScripts: ['/static/js/main.js'],
// Shell is ready - start streaming
onShellReady() {
clearTimeout(timeoutId);
const headers = new Headers({
'Content-Type': 'text/html; charset=utf-8',
'Transfer-Encoding': 'chunked',
// Enable early hints
'Link': [
'</static/js/main.js>; rel=preload; as=script',
'</static/css/main.css>; rel=preload; as=style',
].join(', '),
});
const stream = new ReadableStream({
start(controller) {
pipe({
write(chunk: Uint8Array) {
controller.enqueue(chunk);
},
end() {
controller.close();
},
});
},
});
resolve(new Response(stream, { headers }));
},
// Shell errored - fall back to client render
onShellError(error) {
clearTimeout(timeoutId);
resolve(new Response(
renderClientFallback(url),
{
status: 500,
headers: { 'Content-Type': 'text/html' },
}
));
},
// All content ready (for bots/crawlers)
onAllReady() {
// Log for monitoring
logRenderComplete(url);
},
// Handle errors in streaming content
onError(error) {
logRenderError(url, error);
},
}
);
// Handle timeout
abortController.signal.addEventListener('abort', () => {
abort();
reject(new Error('Render timeout'));
});
});
}
Progressive Hydration Strategy
// Progressive hydration with priority queues
import { hydrateRoot } from 'react-dom/client';
type HydrationPriority = 'critical' | 'high' | 'medium' | 'low' | 'idle';
interface HydrationTask {
id: string;
priority: HydrationPriority;
element: Element;
component: React.ComponentType;
props: Record<string, unknown>;
}
class HydrationScheduler {
private queues: Map<HydrationPriority, HydrationTask[]> = new Map([
['critical', []],
['high', []],
['medium', []],
['low', []],
['idle', []],
]);
private isHydrating = false;
private hydratedElements = new Set<string>();
// Priority order for processing
private readonly priorityOrder: HydrationPriority[] = [
'critical', 'high', 'medium', 'low', 'idle'
];
schedule(task: HydrationTask) {
if (this.hydratedElements.has(task.id)) return;
const queue = this.queues.get(task.priority)!;
queue.push(task);
this.processQueues();
}
private async processQueues() {
if (this.isHydrating) return;
this.isHydrating = true;
for (const priority of this.priorityOrder) {
const queue = this.queues.get(priority)!;
while (queue.length > 0) {
const task = queue.shift()!;
// Check if element is in viewport (for non-critical)
if (priority !== 'critical' && !this.isInViewport(task.element)) {
// Re-queue with lower priority
const lowerPriority = this.getLowerPriority(priority);
if (lowerPriority) {
task.priority = lowerPriority;
this.schedule(task);
}
continue;
}
await this.hydrateElement(task);
// Yield to main thread between hydrations
if (priority !== 'critical') {
await this.yieldToMain();
}
}
}
this.isHydrating = false;
}
private async hydrateElement(task: HydrationTask) {
const { id, element, component: Component, props } = task;
// Mark hydration start for performance tracking
performance.mark(`hydration-start-${id}`);
try {
hydrateRoot(element, <Component {...props} />);
this.hydratedElements.add(id);
// Track hydration performance
performance.mark(`hydration-end-${id}`);
performance.measure(
`hydration-${id}`,
`hydration-start-${id}`,
`hydration-end-${id}`
);
} catch (error) {
// Fall back to client render on hydration mismatch
console.error(`Hydration failed for ${id}:`, error);
element.innerHTML = '';
const root = createRoot(element);
root.render(<Component {...props} />);
}
}
private isInViewport(element: Element): boolean {
const rect = element.getBoundingClientRect();
return (
rect.top < window.innerHeight + 100 && // 100px threshold
rect.bottom > -100
);
}
private getLowerPriority(
current: HydrationPriority
): HydrationPriority | null {
const index = this.priorityOrder.indexOf(current);
return index < this.priorityOrder.length - 1
? this.priorityOrder[index + 1]
: null;
}
private yieldToMain(): Promise<void> {
return new Promise(resolve => {
if ('scheduler' in globalThis && 'yield' in scheduler) {
scheduler.yield().then(resolve);
} else {
setTimeout(resolve, 0);
}
});
}
}
// Usage in page component
const hydrationScheduler = new HydrationScheduler();
// Critical: search input, filters (immediate interaction)
hydrationScheduler.schedule({
id: 'search-bar',
priority: 'critical',
element: document.getElementById('search-bar')!,
component: SearchBar,
props: { initialQuery: window.__INITIAL_QUERY__ },
});
// High: first visible listing cards
document.querySelectorAll('.listing-card:nth-child(-n+6)').forEach((el, i) => {
hydrationScheduler.schedule({
id: `listing-${i}`,
priority: 'high',
element: el,
component: ListingCard,
props: window.__LISTINGS__[i],
});
});
// Medium: map (complex but not immediately interactive)
hydrationScheduler.schedule({
id: 'map',
priority: 'medium',
element: document.getElementById('map-container')!,
component: Map,
props: { bounds: window.__MAP_BOUNDS__ },
});
// Low: below-fold listings
document.querySelectorAll('.listing-card:nth-child(n+7)').forEach((el, i) => {
hydrationScheduler.schedule({
id: `listing-${i + 6}`,
priority: 'low',
element: el,
component: ListingCard,
props: window.__LISTINGS__[i + 6],
});
});
// Idle: footer, secondary navigation
hydrationScheduler.schedule({
id: 'footer',
priority: 'idle',
element: document.getElementById('footer')!,
component: Footer,
props: {},
});
6. Image Pipeline Architecture
Images are the single largest factor in Airbnb's performance. Their image pipeline is a sophisticated system handling 500M+ images:
┌─────────────────────────────────────────────────────────────────────────────┐
│ AIRBNB IMAGE PIPELINE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ UPLOAD PROCESSING │
│ ┌─────────┐ ┌─────────────────────────────────────┐ │
│ │ Host │──── Original ────▶ │ Thumbor Processing Cluster │ │
│ │ Upload │ (10-50MB) │ │ │
│ │ │ │ ┌─────────┐ ┌─────────┐ ┌────────┐ │ │
│ └─────────┘ │ │ Resize │ │ Format │ │Quality │ │ │
│ │ │ (12 │ │ Convert │ │Optimize│ │ │
│ │ │ sizes) │ │ AVIF/ │ │ (SSIM) │ │ │
│ │ │ │ │ WebP/ │ │ │ │ │
│ │ │ │ │ JPG │ │ │ │ │
│ │ └─────────┘ └─────────┘ └────────┘ │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ STORAGE ┌─────────────────────────────────────┐ │
│ ┌─────────────────────────────┐│ CDN EDGE CACHE │ │
│ │ S3 Origin ││ │ │
│ │ • Original preserved ││ ┌─────────┐ ┌─────────┐ ┌────────┐ │ │
│ │ • All variants generated ││ │ Fastly │ │ Cloud │ │ Akamai │ │ │
│ │ • ~36 variants per image ││ │ (US/EU) │ │ Front │ │ (APAC) │ │ │
│ │ • BlurHash stored ││ │ │ │ (LATAM) │ │ │ │ │
│ └─────────────────────────────┘│ └─────────┘ └─────────┘ └────────┘ │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ CLIENT SELECTION │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Format Selection: │ │
│ │ Accept: image/avif → AVIF (40% smaller) │ │
│ │ Accept: image/webp → WebP (25% smaller) │ │
│ │ Fallback → Progressive JPEG │ │
│ │ │ │
│ │ Size Selection (srcset): │ │
│ │ 320w → Mobile portrait (1x) │ │
│ │ 640w → Mobile landscape / portrait (2x) │ │
│ │ 960w → Tablet (1x) / Mobile (3x) │ │
│ │ 1280w → Tablet (2x) / Desktop small │ │
│ │ 1920w → Desktop HD │ │
│ │ 2560w → Desktop 4K │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// Image service with intelligent format and size selection
interface ImageConfig {
baseUrl: string;
defaultQuality: number;
formatPreferences: ('avif' | 'webp' | 'jpg')[];
densityBreakpoints: number[];
}
class ImageService {
private config: ImageConfig;
private formatSupport: Map<string, boolean> = new Map();
private connectionType: string = '4g';
constructor(config: ImageConfig) {
this.config = config;
this.detectFormatSupport();
this.detectConnectionType();
}
private async detectFormatSupport() {
// AVIF support detection
const avifSupport = await this.testFormat(
'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKBzgABpAQ0AIAFEAAAPMhyRQ='
);
this.formatSupport.set('avif', avifSupport);
// WebP support detection
const webpSupport = await this.testFormat(
'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=='
);
this.formatSupport.set('webp', webpSupport);
}
private testFormat(dataUri: string): Promise<boolean> {
return new Promise(resolve => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = dataUri;
});
}
private detectConnectionType() {
if ('connection' in navigator) {
const conn = (navigator as any).connection;
this.connectionType = conn.effectiveType || '4g';
conn.addEventListener('change', () => {
this.connectionType = conn.effectiveType || '4g';
});
}
}
// Get optimal format based on support and connection
getOptimalFormat(): 'avif' | 'webp' | 'jpg' {
// On slow connections, prefer WebP over AVIF (faster decode)
if (this.connectionType === '2g' || this.connectionType === 'slow-2g') {
if (this.formatSupport.get('webp')) return 'webp';
return 'jpg';
}
// Normal connections: prefer AVIF for best compression
if (this.formatSupport.get('avif')) return 'avif';
if (this.formatSupport.get('webp')) return 'webp';
return 'jpg';
}
// Get quality based on connection type
getOptimalQuality(): number {
switch (this.connectionType) {
case 'slow-2g':
case '2g':
return 40;
case '3g':
return 60;
case '4g':
default:
return 80;
}
}
// Generate responsive image attributes
generateImageProps(
imageId: string,
options: {
width: number;
height: number;
sizes: string;
priority?: boolean;
}
): {
src: string;
srcSet: string;
sizes: string;
loading: 'eager' | 'lazy';
decoding: 'sync' | 'async';
fetchPriority: 'high' | 'low' | 'auto';
} {
const format = this.getOptimalFormat();
const quality = this.getOptimalQuality();
// Generate srcset for all density breakpoints
const srcSet = this.config.densityBreakpoints
.filter(w => w <= options.width * 3) // Up to 3x density
.map(w => {
const url = this.buildUrl(imageId, {
width: w,
format,
quality
});
return `${url} ${w}w`;
})
.join(', ');
// Default src at 1x size
const src = this.buildUrl(imageId, {
width: options.width,
format,
quality,
});
return {
src,
srcSet,
sizes: options.sizes,
loading: options.priority ? 'eager' : 'lazy',
decoding: options.priority ? 'sync' : 'async',
fetchPriority: options.priority ? 'high' : 'auto',
};
}
private buildUrl(
imageId: string,
params: { width: number; format: string; quality: number }
): string {
const searchParams = new URLSearchParams({
im_w: params.width.toString(),
im_format: params.format,
im_q: params.quality.toString(),
});
return `${this.config.baseUrl}/${imageId}?${searchParams}`;
}
}
// Usage
const imageService = new ImageService({
baseUrl: 'https://a0.muscache.com/im/pictures',
defaultQuality: 80,
formatPreferences: ['avif', 'webp', 'jpg'],
densityBreakpoints: [320, 640, 960, 1280, 1920, 2560],
});
// In component
function ListingImage({ photo, priority }: { photo: Photo; priority: boolean }) {
const imageProps = imageService.generateImageProps(photo.id, {
width: photo.width,
height: photo.height,
sizes: '(max-width: 744px) 100vw, (max-width: 1128px) 50vw, 33vw',
priority,
});
return (
<img
{...imageProps}
alt={photo.alt}
width={photo.width}
height={photo.height}
style={{ aspectRatio: `${photo.width} / ${photo.height}` }}
/>
);
}
7. Map Integration Performance
Maps are the second-largest performance bottleneck after images. Airbnb's approach involves aggressive lazy loading and clustering:
// Map loading strategy with performance optimization
import { lazy, Suspense, useEffect, useState } from 'react';
// Lazy load map library only when needed
const MapboxMap = lazy(() =>
import(/* webpackChunkName: "mapbox" */ './MapboxMap')
);
interface MapContainerProps {
bounds: LatLngBounds;
listings: Listing[];
onBoundsChange: (bounds: LatLngBounds) => void;
}
export function MapContainer({
bounds,
listings,
onBoundsChange
}: MapContainerProps) {
const [shouldLoad, setShouldLoad] = useState(false);
const [isInteracted, setIsInteracted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Load map when:
// 1. Container is in viewport, AND
// 2. User has interacted OR 2 seconds have passed
useEffect(() => {
if (!containerRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
// Start 2-second timer
const timer = setTimeout(() => setShouldLoad(true), 2000);
// Or load immediately on interaction
const handleInteraction = () => {
clearTimeout(timer);
setShouldLoad(true);
setIsInteracted(true);
};
containerRef.current?.addEventListener('mouseenter', handleInteraction);
containerRef.current?.addEventListener('touchstart', handleInteraction);
return () => {
clearTimeout(timer);
containerRef.current?.removeEventListener('mouseenter', handleInteraction);
containerRef.current?.removeEventListener('touchstart', handleInteraction);
};
}
},
{ rootMargin: '100px' }
);
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Preload map chunk on hover before actual load
const preloadMap = useCallback(() => {
import(/* webpackChunkName: "mapbox" */ './MapboxMap');
}, []);
return (
<div
ref={containerRef}
className="map-container"
onMouseEnter={preloadMap}
style={{
aspectRatio: '16 / 9',
minHeight: '400px',
contain: 'strict',
}}
>
{shouldLoad ? (
<Suspense fallback={<MapSkeleton bounds={bounds} listings={listings} />}>
<MapboxMap
bounds={bounds}
listings={listings}
onBoundsChange={onBoundsChange}
interactive={isInteracted}
/>
</Suspense>
) : (
<MapSkeleton
bounds={bounds}
listings={listings}
onClick={() => {
setShouldLoad(true);
setIsInteracted(true);
}}
/>
)}
</div>
);
}
// Static map skeleton that looks like a real map
function MapSkeleton({
bounds,
listings,
onClick,
}: {
bounds: LatLngBounds;
listings: Listing[];
onClick?: () => void;
}) {
// Generate static map image URL
const staticMapUrl = useMemo(() => {
const center = bounds.getCenter();
return `https://api.mapbox.com/styles/v1/airbnb/static/${center.lng},${center.lat},12,0/800x600@2x?access_token=${MAPBOX_TOKEN}`;
}, [bounds]);
// Pre-calculate marker positions for static display
const markerPositions = useMemo(() => {
return listings.slice(0, 20).map(listing => ({
id: listing.id,
x: longitudeToPixel(listing.lng, bounds),
y: latitudeToPixel(listing.lat, bounds),
price: listing.price,
}));
}, [listings, bounds]);
return (
<div
className="map-skeleton"
onClick={onClick}
role="button"
aria-label="Click to load interactive map"
style={{ cursor: 'pointer' }}
>
{/* Static map background */}
<img
src={staticMapUrl}
alt="Map showing listing locations"
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Static price markers */}
<div className="static-markers">
{markerPositions.map(marker => (
<div
key={marker.id}
className="price-marker static"
style={{
position: 'absolute',
left: `${marker.x}%`,
top: `${marker.y}%`,
transform: 'translate(-50%, -50%)',
}}
>
${marker.price}
</div>
))}
</div>
{/* Interaction prompt */}
<div className="map-interaction-hint">
Click to explore map
</div>
</div>
);
}
Map Marker Clustering for Performance
// Supercluster-based marker clustering
import Supercluster from 'supercluster';
import { useMemo, useCallback } from 'react';
interface ClusterFeature {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: {
cluster: boolean;
cluster_id?: number;
point_count?: number;
listing?: Listing;
};
}
function useMarkerClusters(
listings: Listing[],
bounds: LatLngBounds,
zoom: number
) {
// Initialize supercluster with listings
const supercluster = useMemo(() => {
const cluster = new Supercluster({
radius: 60, // Cluster radius in pixels
maxZoom: 16, // Max zoom to cluster at
minPoints: 2, // Minimum points to form a cluster
extent: 512, // Tile extent
nodeSize: 64, // KD-tree node size
});
// Convert listings to GeoJSON features
const features: ClusterFeature[] = listings.map(listing => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [listing.lng, listing.lat],
},
properties: {
cluster: false,
listing,
},
}));
cluster.load(features);
return cluster;
}, [listings]);
// Get clusters for current viewport
const clusters = useMemo(() => {
const bbox: [number, number, number, number] = [
bounds.west,
bounds.south,
bounds.east,
bounds.north,
];
return supercluster.getClusters(bbox, Math.floor(zoom));
}, [supercluster, bounds, zoom]);
// Expand cluster on click
const expandCluster = useCallback((clusterId: number) => {
const expansionZoom = supercluster.getClusterExpansionZoom(clusterId);
const center = supercluster
.getLeaves(clusterId, 1)[0]
.geometry.coordinates;
return { zoom: expansionZoom, center };
}, [supercluster]);
return { clusters, expandCluster };
}
// Marker component with virtualization
function MapMarkers({
clusters,
visibleBounds,
onClusterClick,
onListingClick,
}: MapMarkersProps) {
// Only render markers in visible viewport + buffer
const visibleMarkers = useMemo(() => {
const buffer = 0.1; // 10% buffer
return clusters.filter(cluster => {
const [lng, lat] = cluster.geometry.coordinates;
return (
lng >= visibleBounds.west - buffer &&
lng <= visibleBounds.east + buffer &&
lat >= visibleBounds.south - buffer &&
lat <= visibleBounds.north + buffer
);
});
}, [clusters, visibleBounds]);
return (
<>
{visibleMarkers.map(feature => {
if (feature.properties.cluster) {
return (
<ClusterMarker
key={`cluster-${feature.properties.cluster_id}`}
coordinates={feature.geometry.coordinates}
count={feature.properties.point_count!}
onClick={() => onClusterClick(feature.properties.cluster_id!)}
/>
);
}
return (
<ListingMarker
key={`listing-${feature.properties.listing!.id}`}
listing={feature.properties.listing!}
onClick={() => onListingClick(feature.properties.listing!)}
/>
);
})}
</>
);
}
8. Search & Filter Performance
Search is Airbnb's most interaction-heavy page. Every filter change, date selection, or map pan triggers complex state updates:
// Search state machine with performance optimizations
import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';
interface SearchContext {
query: string;
location: GeoLocation | null;
dates: { checkIn: Date | null; checkOut: Date | null };
guests: { adults: number; children: number; infants: number };
filters: FilterState;
results: Listing[];
mapBounds: LatLngBounds | null;
isMapView: boolean;
pagination: { page: number; hasMore: boolean };
}
type SearchEvent =
| { type: 'SET_QUERY'; query: string }
| { type: 'SET_LOCATION'; location: GeoLocation }
| { type: 'SET_DATES'; dates: SearchContext['dates'] }
| { type: 'SET_GUESTS'; guests: SearchContext['guests'] }
| { type: 'SET_FILTER'; key: string; value: FilterValue }
| { type: 'CLEAR_FILTERS' }
| { type: 'SET_MAP_BOUNDS'; bounds: LatLngBounds }
| { type: 'TOGGLE_MAP_VIEW' }
| { type: 'LOAD_MORE' }
| { type: 'SEARCH_SUCCESS'; results: Listing[]; hasMore: boolean }
| { type: 'SEARCH_ERROR'; error: Error };
const searchMachine = createMachine<SearchContext, SearchEvent>({
id: 'search',
initial: 'idle',
context: {
query: '',
location: null,
dates: { checkIn: null, checkOut: null },
guests: { adults: 1, children: 0, infants: 0 },
filters: defaultFilters,
results: [],
mapBounds: null,
isMapView: false,
pagination: { page: 1, hasMore: true },
},
states: {
idle: {
on: {
SET_QUERY: {
actions: 'updateQuery',
target: 'debouncing',
},
SET_LOCATION: {
actions: 'updateLocation',
target: 'searching',
},
SET_DATES: {
actions: 'updateDates',
target: 'searching',
},
SET_GUESTS: {
actions: 'updateGuests',
target: 'searching',
},
SET_FILTER: {
actions: 'updateFilter',
target: 'debouncing',
},
SET_MAP_BOUNDS: {
actions: 'updateMapBounds',
target: 'debouncing',
},
LOAD_MORE: {
target: 'loadingMore',
},
},
},
// Debounce rapid changes (typing, filter toggles)
debouncing: {
after: {
300: 'searching', // 300ms debounce
},
on: {
// Cancel debounce on new input
SET_QUERY: {
actions: 'updateQuery',
target: 'debouncing',
},
SET_FILTER: {
actions: 'updateFilter',
target: 'debouncing',
},
SET_MAP_BOUNDS: {
actions: 'updateMapBounds',
target: 'debouncing',
},
},
},
searching: {
invoke: {
src: 'performSearch',
onDone: {
actions: 'setResults',
target: 'idle',
},
onError: {
actions: 'setError',
target: 'error',
},
},
},
loadingMore: {
invoke: {
src: 'loadMoreResults',
onDone: {
actions: 'appendResults',
target: 'idle',
},
onError: {
target: 'idle',
},
},
},
error: {
on: {
'*': 'debouncing', // Any action retries search
},
},
},
}, {
actions: {
updateQuery: assign({ query: (_, event) => event.query }),
updateLocation: assign({ location: (_, event) => event.location }),
updateDates: assign({ dates: (_, event) => event.dates }),
updateGuests: assign({ guests: (_, event) => event.guests }),
updateFilter: assign({
filters: (context, event) => ({
...context.filters,
[event.key]: event.value,
}),
}),
updateMapBounds: assign({ mapBounds: (_, event) => event.bounds }),
setResults: assign({
results: (_, event) => event.data.results,
pagination: (_, event) => ({ page: 1, hasMore: event.data.hasMore }),
}),
appendResults: assign({
results: (context, event) => [...context.results, ...event.data.results],
pagination: (context, event) => ({
page: context.pagination.page + 1,
hasMore: event.data.hasMore,
}),
}),
},
services: {
performSearch: async (context) => {
// Build search params from context
const params = buildSearchParams(context);
// Parallel fetch: listings + count + price histogram
const [results, count, priceHistogram] = await Promise.all([
fetchListings(params),
fetchResultCount(params),
fetchPriceHistogram(params),
]);
return { results, count, priceHistogram, hasMore: count > 20 };
},
loadMoreResults: async (context) => {
const params = buildSearchParams(context);
params.page = context.pagination.page + 1;
const results = await fetchListings(params);
return { results, hasMore: results.length === 20 };
},
},
});
// URL synchronization hook
function useSearchUrlSync(state: SearchContext, send: (event: SearchEvent) => void) {
const router = useRouter();
// Sync URL to state on mount
useEffect(() => {
const params = parseSearchParams(router.query);
if (params.query) send({ type: 'SET_QUERY', query: params.query });
if (params.location) send({ type: 'SET_LOCATION', location: params.location });
if (params.dates) send({ type: 'SET_DATES', dates: params.dates });
// ... more params
}, []);
// Sync state to URL on change (debounced)
useEffect(() => {
const timeoutId = setTimeout(() => {
const url = buildSearchUrl(state);
router.replace(url, undefined, { shallow: true });
}, 100);
return () => clearTimeout(timeoutId);
}, [state.query, state.location, state.dates, state.filters]);
}
Filter UI with Optimized Rendering
// Virtualized filter options for large lists
import { FixedSizeList } from 'react-window';
interface AmenityFilterProps {
amenities: Amenity[];
selected: Set<string>;
onToggle: (id: string) => void;
}
export function AmenityFilter({
amenities,
selected,
onToggle
}: AmenityFilterProps) {
const [searchTerm, setSearchTerm] = useState('');
// Filter and sort amenities
const filteredAmenities = useMemo(() => {
const filtered = amenities.filter(a =>
a.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Selected items first, then alphabetical
return filtered.sort((a, b) => {
const aSelected = selected.has(a.id);
const bSelected = selected.has(b.id);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
return a.name.localeCompare(b.name);
});
}, [amenities, selected, searchTerm]);
// Optimistic toggle with rollback
const handleToggle = useCallback((id: string) => {
// Immediate UI update
startTransition(() => {
onToggle(id);
});
}, [onToggle]);
// Row renderer for virtualization
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
const amenity = filteredAmenities[index];
const isSelected = selected.has(amenity.id);
return (
<label
key={amenity.id}
style={style}
className={`amenity-option ${isSelected ? 'selected' : ''}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggle(amenity.id)}
/>
<span className="amenity-icon">{amenity.icon}</span>
<span className="amenity-name">{amenity.name}</span>
{amenity.count && (
<span className="amenity-count">({amenity.count})</span>
)}
</label>
);
}, [filteredAmenities, selected, handleToggle]);
return (
<div className="amenity-filter">
<input
type="search"
placeholder="Search amenities..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="amenity-search"
/>
{filteredAmenities.length > 10 ? (
// Virtualize for large lists
<FixedSizeList
height={400}
itemCount={filteredAmenities.length}
itemSize={48}
width="100%"
>
{Row}
</FixedSizeList>
) : (
// Direct render for small lists
<div className="amenity-list">
{filteredAmenities.map((amenity, index) => (
<Row key={amenity.id} index={index} style={{}} />
))}
</div>
)}
</div>
);
}
9. Calendar & Date Picker Performance
The date picker is one of Airbnb's most complex interactive components—it must show availability, pricing variations, and blocked dates while remaining responsive:
// Performance-optimized calendar with availability
import { memo, useMemo, useCallback } from 'react';
interface CalendarProps {
availability: Map<string, AvailabilityDay>;
selectedRange: { start: Date | null; end: Date | null };
onSelect: (date: Date) => void;
minStay: number;
maxStay: number;
}
// Memoized day component to prevent re-renders
const CalendarDay = memo(function CalendarDay({
date,
availability,
isSelected,
isInRange,
isCheckIn,
isCheckOut,
isDisabled,
priceVariation,
onClick,
}: CalendarDayProps) {
const handleClick = useCallback(() => {
if (!isDisabled) {
onClick(date);
}
}, [date, isDisabled, onClick]);
const className = useMemo(() => {
const classes = ['calendar-day'];
if (isSelected) classes.push('selected');
if (isInRange) classes.push('in-range');
if (isCheckIn) classes.push('check-in');
if (isCheckOut) classes.push('check-out');
if (isDisabled) classes.push('disabled');
if (availability?.isWeekend) classes.push('weekend');
return classes.join(' ');
}, [isSelected, isInRange, isCheckIn, isCheckOut, isDisabled, availability]);
return (
<button
type="button"
className={className}
onClick={handleClick}
disabled={isDisabled}
aria-label={formatDateLabel(date, availability)}
>
<span className="day-number">{date.getDate()}</span>
{priceVariation && (
<span className={`price-indicator ${priceVariation}`}>
{priceVariation === 'high' ? '↑' : priceVariation === 'low' ? '↓' : ''}
</span>
)}
</button>
);
});
export function Calendar({
availability,
selectedRange,
onSelect,
minStay,
maxStay,
}: CalendarProps) {
// Pre-compute 12 months of calendar data
const calendarMonths = useMemo(() => {
const months: CalendarMonth[] = [];
const today = new Date();
for (let i = 0; i < 12; i++) {
const monthDate = addMonths(today, i);
const days = getDaysInMonth(monthDate);
months.push({
date: monthDate,
days: days.map(day => {
const key = formatDateKey(day);
const dayAvailability = availability.get(key);
return {
date: day,
availability: dayAvailability,
isDisabled: !dayAvailability?.isAvailable || isPast(day),
priceVariation: dayAvailability?.priceVariation,
};
}),
});
}
return months;
}, [availability]);
// Compute selection state for all days
const selectionState = useMemo(() => {
const state = new Map<string, DaySelectionState>();
if (selectedRange.start) {
state.set(formatDateKey(selectedRange.start), {
isSelected: true,
isCheckIn: true
});
if (selectedRange.end) {
state.set(formatDateKey(selectedRange.end), {
isSelected: true,
isCheckOut: true
});
// Mark in-range days
let current = addDays(selectedRange.start, 1);
while (current < selectedRange.end) {
state.set(formatDateKey(current), { isInRange: true });
current = addDays(current, 1);
}
}
}
return state;
}, [selectedRange]);
// Virtualized month rendering
const renderMonth = useCallback((month: CalendarMonth, index: number) => {
return (
<div key={month.date.toISOString()} className="calendar-month">
<h3 className="month-header">
{format(month.date, 'MMMM yyyy')}
</h3>
<div className="calendar-grid" role="grid">
{/* Day headers */}
<div className="day-headers">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => (
<div key={day} className="day-header">{day}</div>
))}
</div>
{/* Day cells */}
<div className="days-grid">
{month.days.map(day => {
const key = formatDateKey(day.date);
const state = selectionState.get(key) || {};
return (
<CalendarDay
key={key}
date={day.date}
availability={day.availability}
isSelected={state.isSelected || false}
isInRange={state.isInRange || false}
isCheckIn={state.isCheckIn || false}
isCheckOut={state.isCheckOut || false}
isDisabled={day.isDisabled}
priceVariation={day.priceVariation}
onClick={onSelect}
/>
);
})}
</div>
</div>
</div>
);
}, [selectionState, onSelect]);
return (
<div className="calendar-container">
{/* Intersection Observer for lazy loading months */}
<IntersectionObserverList
items={calendarMonths}
renderItem={renderMonth}
rootMargin="200px"
className="months-container"
/>
</div>
);
}
// Efficient intersection observer for lazy rendering
function IntersectionObserverList<T>({
items,
renderItem,
rootMargin,
className,
}: {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
rootMargin: string;
className?: string;
}) {
const [visibleIndices, setVisibleIndices] = useState<Set<number>>(
() => new Set([0, 1, 2]) // Initially render first 3
);
const observerRef = useRef<IntersectionObserver | null>(null);
const itemRefs = useRef<Map<number, HTMLDivElement>>(new Map());
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
const index = parseInt(entry.target.getAttribute('data-index')!, 10);
setVisibleIndices(prev => {
const next = new Set(prev);
if (entry.isIntersecting) {
next.add(index);
// Also add adjacent months for smooth scrolling
if (index > 0) next.add(index - 1);
if (index < items.length - 1) next.add(index + 1);
}
return next;
});
});
},
{ rootMargin }
);
// Observe all item containers
itemRefs.current.forEach((el) => {
observerRef.current?.observe(el);
});
return () => observerRef.current?.disconnect();
}, [items.length, rootMargin]);
return (
<div className={className}>
{items.map((item, index) => (
<div
key={index}
ref={(el) => el && itemRefs.current.set(index, el)}
data-index={index}
style={{ minHeight: visibleIndices.has(index) ? undefined : '300px' }}
>
{visibleIndices.has(index) ? renderItem(item, index) : null}
</div>
))}
</div>
);
}
10. Data Fetching & Caching Strategy
Airbnb uses a combination of Apollo Client for GraphQL and custom caching for critical paths:
// Multi-layer caching architecture
import { ApolloClient, InMemoryCache, ApolloLink } from '@apollo/client';
// Cache configuration with custom merge functions
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Search results: replace on new search, merge on pagination
searchListings: {
keyArgs: ['query', 'location', 'dates', 'guests', 'filters'],
merge(existing, incoming, { args }) {
if (args?.page === 1 || !existing) {
return incoming;
}
return {
...incoming,
listings: [...existing.listings, ...incoming.listings],
};
},
},
// Single listing: always cache by ID
listing: {
read(_, { args, toReference }) {
return toReference({ __typename: 'Listing', id: args?.id });
},
},
},
},
Listing: {
fields: {
// Price can change based on dates - don't cache aggressively
pricing: {
merge: false,
},
// Availability changes frequently
availability: {
merge(existing, incoming) {
// Only update if newer
if (!existing || incoming.updatedAt > existing.updatedAt) {
return incoming;
}
return existing;
},
},
// Reviews rarely change - cache aggressively
reviews: {
keyArgs: ['listingId'],
merge(existing, incoming, { args }) {
if (args?.offset === 0) return incoming;
return {
...incoming,
items: [...(existing?.items || []), ...incoming.items],
};
},
},
},
},
},
});
// Custom link for request deduplication
const dedupeLink = new ApolloLink((operation, forward) => {
const key = operation.operationName + JSON.stringify(operation.variables);
// Check if identical request is in flight
if (inflightRequests.has(key)) {
return inflightRequests.get(key)!;
}
const observable = forward(operation);
inflightRequests.set(key, observable);
// Clean up after response
observable.subscribe({
complete: () => inflightRequests.delete(key),
error: () => inflightRequests.delete(key),
});
return observable;
});
// Prefetch critical data based on user behavior
class PrefetchManager {
private prefetchQueue: Set<string> = new Set();
private prefetchedIds: Set<string> = new Set();
// Prefetch listing on hover
prefetchListing(listingId: string) {
if (this.prefetchedIds.has(listingId)) return;
this.prefetchQueue.add(listingId);
this.schedulePrefetch();
}
private schedulePrefetch = debounce(() => {
const ids = Array.from(this.prefetchQueue);
this.prefetchQueue.clear();
// Batch prefetch multiple listings
requestIdleCallback(() => {
apolloClient.query({
query: LISTING_PREVIEW_QUERY,
variables: { ids },
fetchPolicy: 'cache-first',
});
ids.forEach(id => this.prefetchedIds.add(id));
});
}, 100);
// Prefetch search results based on likely navigation
prefetchSearchVariation(baseParams: SearchParams, variations: Partial<SearchParams>[]) {
requestIdleCallback(() => {
variations.forEach(variation => {
const params = { ...baseParams, ...variation };
apolloClient.query({
query: SEARCH_LISTINGS_QUERY,
variables: params,
fetchPolicy: 'cache-first',
});
});
});
}
}
// Usage in listing card
function ListingCard({ listing }: { listing: Listing }) {
const prefetchManager = usePrefetchManager();
const handleMouseEnter = useCallback(() => {
prefetchManager.prefetchListing(listing.id);
}, [listing.id, prefetchManager]);
return (
<article
className="listing-card"
onMouseEnter={handleMouseEnter}
>
{/* ... */}
</article>
);
}
11. Performance Monitoring & RUM
Airbnb has comprehensive Real User Monitoring to track Core Web Vitals in production:
// Performance monitoring system
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
interface PerformanceMetric {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
attribution?: Record<string, unknown>;
}
class PerformanceMonitor {
private metrics: PerformanceMetric[] = [];
private sessionId: string;
private pageId: string;
constructor() {
this.sessionId = crypto.randomUUID();
this.pageId = crypto.randomUUID();
this.initWebVitals();
this.initCustomMetrics();
this.initLongTaskObserver();
}
private initWebVitals() {
// Largest Contentful Paint
onLCP((metric) => {
this.report({
name: 'LCP',
value: metric.value,
rating: metric.rating,
attribution: {
element: metric.attribution?.element?.tagName,
url: metric.attribution?.url,
resourceLoadDelay: metric.attribution?.resourceLoadDelay,
elementRenderDelay: metric.attribution?.elementRenderDelay,
},
});
}, { reportAllChanges: true });
// Interaction to Next Paint
onINP((metric) => {
this.report({
name: 'INP',
value: metric.value,
rating: metric.rating,
attribution: {
eventType: metric.attribution?.eventType,
eventTarget: metric.attribution?.eventTarget,
interactionTarget: metric.attribution?.interactionTarget,
loadState: metric.attribution?.loadState,
},
});
});
// Cumulative Layout Shift
onCLS((metric) => {
this.report({
name: 'CLS',
value: metric.value,
rating: metric.rating,
attribution: {
largestShiftTarget: metric.attribution?.largestShiftTarget,
largestShiftValue: metric.attribution?.largestShiftValue,
largestShiftTime: metric.attribution?.largestShiftTime,
},
});
});
// First Contentful Paint
onFCP((metric) => {
this.report({
name: 'FCP',
value: metric.value,
rating: metric.rating,
});
});
// Time to First Byte
onTTFB((metric) => {
this.report({
name: 'TTFB',
value: metric.value,
rating: metric.rating,
attribution: {
waitingDuration: metric.attribution?.waitingDuration,
cacheDuration: metric.attribution?.cacheDuration,
dnsDuration: metric.attribution?.dnsDuration,
connectionDuration: metric.attribution?.connectionDuration,
requestDuration: metric.attribution?.requestDuration,
},
});
});
}
private initCustomMetrics() {
// Track search result render time
this.observeCustomMetric('search-results-render', () => {
const searchStart = performance.getEntriesByName('search-start')[0];
const searchEnd = performance.getEntriesByName('search-results-visible')[0];
if (searchStart && searchEnd) {
return searchEnd.startTime - searchStart.startTime;
}
return null;
});
// Track image load completion
this.observeCustomMetric('above-fold-images-loaded', () => {
const images = document.querySelectorAll('img[data-priority="high"]');
const allLoaded = Array.from(images).every(
img => (img as HTMLImageElement).complete
);
if (allLoaded) {
return performance.now();
}
return null;
});
// Track map interactive
this.observeCustomMetric('map-interactive', () => {
const mapReady = performance.getEntriesByName('map-interactive')[0];
return mapReady?.startTime || null;
});
}
private initLongTaskObserver() {
if (!('PerformanceObserver' in window)) return;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
this.report({
name: 'long-task',
value: entry.duration,
rating: entry.duration > 100 ? 'poor' : 'needs-improvement',
attribution: {
startTime: entry.startTime,
attribution: (entry as any).attribution,
},
});
}
}
});
observer.observe({ type: 'longtask', buffered: true });
}
private observeCustomMetric(
name: string,
getValue: () => number | null
) {
const checkValue = () => {
const value = getValue();
if (value !== null) {
this.report({
name,
value,
rating: this.rateCustomMetric(name, value),
});
return true;
}
return false;
};
// Check immediately
if (checkValue()) return;
// Poll until value available
const interval = setInterval(() => {
if (checkValue()) {
clearInterval(interval);
}
}, 100);
// Stop after 30 seconds
setTimeout(() => clearInterval(interval), 30000);
}
private rateCustomMetric(
name: string,
value: number
): 'good' | 'needs-improvement' | 'poor' {
const thresholds: Record<string, [number, number]> = {
'search-results-render': [1000, 2500],
'above-fold-images-loaded': [2000, 4000],
'map-interactive': [3000, 5000],
};
const [good, poor] = thresholds[name] || [1000, 3000];
if (value <= good) return 'good';
if (value <= poor) return 'needs-improvement';
return 'poor';
}
private report(metric: PerformanceMetric) {
this.metrics.push(metric);
// Send to analytics
navigator.sendBeacon('/api/analytics/performance', JSON.stringify({
sessionId: this.sessionId,
pageId: this.pageId,
url: window.location.href,
userAgent: navigator.userAgent,
connection: (navigator as any).connection?.effectiveType,
deviceMemory: (navigator as any).deviceMemory,
...metric,
timestamp: Date.now(),
}));
}
}
// Initialize on page load
if (typeof window !== 'undefined') {
new PerformanceMonitor();
}
12. Bundle Optimization
Airbnb uses aggressive code splitting and tree shaking to minimize JavaScript payload:
// Webpack configuration for optimal bundling
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
const config: webpack.Configuration = {
optimization: {
// Split chunks for optimal caching
splitChunks: {
chunks: 'all',
maxInitialRequests: 25,
minSize: 20000,
cacheGroups: {
// Core framework (rarely changes)
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'framework',
priority: 40,
enforce: true,
},
// Design system (changes with releases)
designSystem: {
test: /[\\/]node_modules[\\/]@airbnb[\\/]dls[\\/]/,
name: 'design-system',
priority: 30,
},
// Vendor libraries (grouped by update frequency)
vendorHigh: {
test: /[\\/]node_modules[\\/](lodash|moment|date-fns)[\\/]/,
name: 'vendor-utils',
priority: 20,
},
// Map libraries (only loaded on search page)
maps: {
test: /[\\/]node_modules[\\/](mapbox-gl|supercluster)[\\/]/,
name: 'maps',
priority: 20,
chunks: 'async',
},
// Default vendor chunk
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
},
// Shared components across pages
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true,
},
},
},
// Module IDs for long-term caching
moduleIds: 'deterministic',
// Runtime chunk for better caching
runtimeChunk: 'single',
},
plugins: [
// Analyze bundle in CI
process.env.ANALYZE && new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
}),
// Define feature flags for tree shaking
new webpack.DefinePlugin({
'process.env.FEATURE_NEW_SEARCH': JSON.stringify(
process.env.FEATURE_NEW_SEARCH === 'true'
),
}),
].filter(Boolean),
};
// Route-based code splitting
const routes = {
'/': () => import(/* webpackChunkName: "home" */ './pages/Home'),
'/s/:location': () => import(/* webpackChunkName: "search" */ './pages/Search'),
'/rooms/:id': () => import(/* webpackChunkName: "listing" */ './pages/Listing'),
'/book/:id': () => import(/* webpackChunkName: "booking" */ './pages/Booking'),
'/hosting': () => import(/* webpackChunkName: "hosting" */ './pages/Hosting'),
'/account': () => import(/* webpackChunkName: "account" */ './pages/Account'),
};
// Prefetch likely next routes
function usePrefetchRoutes(currentRoute: string) {
useEffect(() => {
const prefetchMap: Record<string, string[]> = {
'/': ['/s/:location'], // Home → Search
'/s/:location': ['/rooms/:id'], // Search → Listing
'/rooms/:id': ['/book/:id'], // Listing → Booking
};
const toPrefetch = prefetchMap[currentRoute] || [];
// Prefetch after idle
requestIdleCallback(() => {
toPrefetch.forEach(route => {
const loader = routes[route];
if (loader) {
loader(); // Triggers chunk prefetch
}
});
});
}, [currentRoute]);
}
13. Third-Party Script Management
Third-party scripts (analytics, A/B testing, ads) can devastate Core Web Vitals. Airbnb uses a strict loading strategy:
// Third-party script manager with performance controls
type ScriptPriority = 'critical' | 'high' | 'medium' | 'low' | 'idle';
interface ThirdPartyScript {
id: string;
src: string;
priority: ScriptPriority;
loadCondition?: () => boolean;
onLoad?: () => void;
timeout?: number;
}
class ThirdPartyScriptManager {
private scripts: Map<string, ThirdPartyScript> = new Map();
private loadedScripts: Set<string> = new Set();
private failedScripts: Set<string> = new Set();
register(script: ThirdPartyScript) {
this.scripts.set(script.id, script);
}
async loadAll() {
// Group by priority
const byPriority = new Map<ScriptPriority, ThirdPartyScript[]>();
this.scripts.forEach(script => {
const list = byPriority.get(script.priority) || [];
list.push(script);
byPriority.set(script.priority, list);
});
// Load in priority order
const priorities: ScriptPriority[] = ['critical', 'high', 'medium', 'low', 'idle'];
for (const priority of priorities) {
const scripts = byPriority.get(priority) || [];
if (priority === 'critical') {
// Critical scripts: load immediately, block render if needed
await Promise.all(scripts.map(s => this.loadScript(s)));
} else if (priority === 'high') {
// High priority: load after FCP
await this.waitForFCP();
await Promise.all(scripts.map(s => this.loadScript(s)));
} else if (priority === 'medium') {
// Medium: load after LCP
await this.waitForLCP();
scripts.forEach(s => this.loadScript(s));
} else if (priority === 'low') {
// Low: load on idle
requestIdleCallback(() => {
scripts.forEach(s => this.loadScript(s));
});
} else {
// Idle: load after everything else
setTimeout(() => {
requestIdleCallback(() => {
scripts.forEach(s => this.loadScript(s));
});
}, 5000);
}
}
}
private async loadScript(script: ThirdPartyScript): Promise<void> {
if (this.loadedScripts.has(script.id)) return;
if (script.loadCondition && !script.loadCondition()) return;
return new Promise((resolve, reject) => {
const el = document.createElement('script');
el.src = script.src;
el.async = true;
// Add resource hints
el.setAttribute('importance', this.getImportance(script.priority));
const timeout = script.timeout || 10000;
const timeoutId = setTimeout(() => {
this.failedScripts.add(script.id);
reject(new Error(`Script ${script.id} timed out`));
}, timeout);
el.onload = () => {
clearTimeout(timeoutId);
this.loadedScripts.add(script.id);
script.onLoad?.();
resolve();
};
el.onerror = () => {
clearTimeout(timeoutId);
this.failedScripts.add(script.id);
reject(new Error(`Script ${script.id} failed to load`));
};
document.head.appendChild(el);
});
}
private getImportance(priority: ScriptPriority): string {
switch (priority) {
case 'critical': return 'high';
case 'high': return 'high';
case 'medium': return 'auto';
default: return 'low';
}
}
private waitForFCP(): Promise<void> {
return new Promise(resolve => {
if (performance.getEntriesByName('first-contentful-paint').length > 0) {
resolve();
return;
}
const observer = new PerformanceObserver((list) => {
if (list.getEntriesByName('first-contentful-paint').length > 0) {
observer.disconnect();
resolve();
}
});
observer.observe({ type: 'paint', buffered: true });
});
}
private waitForLCP(): Promise<void> {
return new Promise(resolve => {
const observer = new PerformanceObserver((list) => {
// LCP is the last entry
const entries = list.getEntries();
if (entries.length > 0) {
// Wait a bit more to ensure LCP is final
setTimeout(() => {
observer.disconnect();
resolve();
}, 500);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// Fallback timeout
setTimeout(resolve, 5000);
});
}
}
// Script registration
const scriptManager = new ThirdPartyScriptManager();
// Critical: consent management (GDPR)
scriptManager.register({
id: 'consent-manager',
src: '/scripts/consent.js',
priority: 'critical',
});
// High: analytics (after consent)
scriptManager.register({
id: 'analytics',
src: 'https://analytics.airbnb.com/a.js',
priority: 'high',
loadCondition: () => hasConsent('analytics'),
});
// Medium: A/B testing
scriptManager.register({
id: 'experiments',
src: '/scripts/experiments.js',
priority: 'medium',
});
// Low: customer support chat
scriptManager.register({
id: 'support-chat',
src: 'https://support.airbnb.com/chat.js',
priority: 'low',
loadCondition: () => !isGuestCheckout(),
});
// Idle: marketing pixels
scriptManager.register({
id: 'marketing',
src: '/scripts/marketing.js',
priority: 'idle',
loadCondition: () => hasConsent('marketing'),
});
// Initialize after DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => scriptManager.loadAll());
} else {
scriptManager.loadAll();
}
14. Architecture Evolution & Lessons Learned
Evolution Timeline
┌─────────────────────────────────────────────────────────────────────────────┐
│ AIRBNB FRONTEND ARCHITECTURE EVOLUTION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2014 2016 2018 2020 2022 2024 │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Rails│ │React│ │Hyper│ │Apollo│ │ RSC │ │Niobe│ │
│ │ERB │ │ + │ │nova │ │ + │ │Adopt│ │ + │ │
│ │ │ │Flux │ │SSR │ │Perf │ │tion │ │Edge │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ Full Client Server GraphQL Server Edge │
│ page render render adoption Components rendering │
│ reload + API + hydrate + caching + streaming + AI │
│ │
│ METRICS EVOLUTION: │
│ ───────────────── │
│ LCP: 8s → 4s → 2.5s → 2.0s → 1.5s → 1.2s │
│ INP: N/A → 500ms → 300ms → 200ms → 150ms → 100ms │
│ CLS: N/A → 0.5 → 0.25 → 0.15 → 0.08 → 0.05 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Lessons
1. Image optimization is never "done"
// Evolution of image strategy
const imageStrategyEvolution = {
2016: {
format: 'jpg',
sizes: [1, 2], // 1x, 2x only
lazy: false,
placeholder: 'none',
},
2018: {
format: 'webp-with-fallback',
sizes: [1, 2, 3],
lazy: 'native',
placeholder: 'blur-up',
},
2020: {
format: 'avif-webp-fallback',
sizes: [320, 640, 960, 1280, 1920],
lazy: 'intersection-observer',
placeholder: 'blurhash',
},
2024: {
format: 'avif-first',
sizes: 'responsive-hints',
lazy: 'priority-based',
placeholder: 'blurhash-with-dominant-color',
fetchPriority: 'explicit',
},
};
2. Hydration is the enemy of INP
// Before: Full page hydration blocked interactions
// 2018 approach
hydrateRoot(document, <App />); // 500ms+ blocking
// After: Selective, prioritized hydration
// 2024 approach
const scheduler = new HydrationScheduler();
scheduler.schedule({ priority: 'critical', component: SearchBar });
scheduler.schedule({ priority: 'high', component: ListingCards });
scheduler.schedule({ priority: 'idle', component: Footer });
// INP improved from 350ms → 120ms
3. Third-party scripts require constant vigilance
// Tracking impact of third parties over time
const thirdPartyImpact = {
2019: {
scripts: 12,
totalSize: '450KB',
mainThreadTime: '1.2s',
lcpImpact: '+800ms',
},
2024: {
scripts: 8, // Consolidated and removed
totalSize: '180KB', // Optimized bundles
mainThreadTime: '400ms', // Deferred loading
lcpImpact: '+150ms', // Loaded after LCP
},
};
15. Tradeoffs & Anti-Patterns Avoided
Tradeoff: Static Generation vs. Dynamic Pricing
// Challenge: Listing pages could be statically generated for speed,
// but prices change based on dates, availability, and dynamic pricing
// Anti-pattern: Full static generation with client-side price fetch
// Problem: Price "pops in" after load, causes CLS, confuses users
// Solution: Hybrid approach with streaming
async function getStaticProps({ params }) {
// Static: content that rarely changes
const listing = await getListingContent(params.id);
return {
props: { listing },
revalidate: 3600, // Revalidate hourly
};
}
function ListingPage({ listing }) {
return (
<>
{/* Static content renders immediately */}
<ListingGallery photos={listing.photos} />
<ListingDescription listing={listing} />
{/* Price streams in with Suspense */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPricing listingId={listing.id} />
</Suspense>
</>
);
}
// Server component for dynamic pricing
async function DynamicPricing({ listingId }) {
const pricing = await fetchDynamicPricing(listingId);
return <PriceDisplay pricing={pricing} />;
}
Tradeoff: Bundle Size vs. Development Velocity
// Challenge: Design Language System (DLS) is comprehensive but large
// Full DLS: 180KB gzipped
// Used components per page: ~20KB worth
// Anti-pattern: Import entire DLS
import { Button, Card, Modal, ... } from '@airbnb/dls'; // 180KB
// Solution: Tree-shakeable exports with component-level splitting
// webpack.config.js
{
resolve: {
alias: {
'@airbnb/dls': '@airbnb/dls/es', // ES modules for tree shaking
},
},
}
// Component imports are now tree-shakeable
import { Button } from '@airbnb/dls/es/Button'; // 3KB
import { Card } from '@airbnb/dls/es/Card'; // 5KB
// Result: 180KB → 25KB average per page
Anti-Pattern Avoided: Over-Fetching GraphQL
// Anti-pattern: One giant query for all page data
const LISTING_PAGE_QUERY = gql`
query ListingPage($id: ID!) {
listing(id: $id) {
# 50+ fields including nested objects
# Most fields not needed above the fold
}
}
`;
// Solution: Split queries by render priority
const LISTING_CRITICAL_QUERY = gql`
query ListingCritical($id: ID!) {
listing(id: $id) {
id
title
photos(first: 5) { url blurHash }
pricing { basePrice currency }
location { city country }
}
}
`;
const LISTING_DEFERRED_QUERY = gql`
query ListingDeferred($id: ID!) {
listing(id: $id) {
description
amenities { id name icon }
host { id name photo superhost }
reviews(first: 10) { id rating comment author { name } }
# ... more fields
}
}
`;
// Load critical first, deferred after LCP
function ListingPage({ id }) {
const { data: critical } = useQuery(LISTING_CRITICAL_QUERY, { variables: { id } });
const { data: deferred } = useQuery(LISTING_DEFERRED_QUERY, {
variables: { id },
skip: !critical, // Wait for critical
fetchPolicy: 'cache-first',
});
// ...
}
16. Future Architecture Direction
Speculative Prefetching with ML
// ML-powered prefetch predictions
interface PrefetchPrediction {
listingId: string;
probability: number;
predictedAction: 'view' | 'book' | 'save';
}
class MLPrefetchManager {
private model: PredictionModel;
async predictNextActions(context: UserContext): Promise<PrefetchPrediction[]> {
const features = this.extractFeatures(context);
const predictions = await this.model.predict(features);
// Only prefetch high-confidence predictions
return predictions
.filter(p => p.probability > 0.7)
.slice(0, 5); // Max 5 prefetches
}
private extractFeatures(context: UserContext): ModelFeatures {
return {
// User behavior signals
scrollVelocity: context.scrollVelocity,
hoverDuration: context.lastHoverDuration,
searchRefinements: context.filterChanges,
// Content signals
priceRange: context.viewedPriceRange,
propertyTypes: context.viewedPropertyTypes,
// Temporal signals
dayOfWeek: new Date().getDay(),
hourOfDay: new Date().getHours(),
daysUntilTrip: context.searchDates?.daysUntil,
};
}
async prefetchPredicted(predictions: PrefetchPrediction[]) {
for (const prediction of predictions) {
if (prediction.predictedAction === 'view') {
// Prefetch listing page
prefetchPage(`/rooms/${prediction.listingId}`);
} else if (prediction.predictedAction === 'book') {
// Prefetch listing + booking flow
prefetchPage(`/rooms/${prediction.listingId}`);
prefetchPage(`/book/${prediction.listingId}`);
}
}
}
}
Edge-First Architecture
// Edge rendering for global performance
// Deployed to 200+ edge locations
export const config = {
runtime: 'edge',
regions: ['iad1', 'sfo1', 'cdg1', 'hnd1', 'syd1'], // Global
};
export default async function handler(request: Request) {
const url = new URL(request.url);
const geo = request.geo; // Edge-provided geolocation
// Localize content at edge
const locale = detectLocale(request.headers, geo);
const currency = getCurrencyForRegion(geo.country);
// Fetch from nearest origin
const origin = selectNearestOrigin(geo);
const data = await fetchFromOrigin(origin, url.pathname);
// Render at edge with localization
const html = await renderToString(
<App
data={data}
locale={locale}
currency={currency}
/>
);
return new Response(html, {
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=60, stale-while-revalidate=600',
'Vary': 'Accept-Language, Accept-Encoding',
},
});
}
Conclusion
Airbnb's frontend architecture represents a masterclass in balancing visual richness with performance. Key takeaways:
-
Images dominate performance - BlurHash placeholders, AVIF/WebP, responsive srcset, and fetchpriority are non-negotiable.
-
Hydration must be strategic - Full-page hydration is an anti-pattern. Priority-based selective hydration keeps INP under 200ms.
-
Maps require special handling - Lazy load, use static placeholders, cluster markers, and defer interactivity.
-
CLS prevention requires engineering discipline - Aspect ratios, skeleton screens, and
containCSS are essential. -
Third-party scripts need governance - Strict priority ordering and post-LCP loading for non-critical scripts.
-
Performance is a feature, not a metric - Every 100ms of LCP improvement directly correlates with booking conversion.
The architecture continues to evolve with edge rendering, ML-powered prefetching, and React Server Components, but the core principle remains: deliver the visual trust signals users need to book a stranger's home, faster than they expect.
Performance targets achieved (p75):
- LCP: 1.8s (target: 2.5s) ✓
- INP: 120ms (target: 200ms) ✓
- CLS: 0.05 (target: 0.1) ✓
- TTFB: 180ms (target: 800ms) ✓
What did you think?