System Design
Part 0 of 5Uber Frontend System Architecture: Engineering Real-Time Ride-Hailing at Global Scale
Uber Frontend System Architecture: Engineering Real-Time Ride-Hailing at Global Scale
1. Product Overview
Uber operates one of the world's most complex real-time matching systems, connecting riders with drivers across 10,000+ cities. But from a frontend perspective, the challenge isn't just displaying a map—it's orchestrating a symphony of real-time data streams, location updates, state synchronization, and split-second UI updates across millions of concurrent sessions.
Scale assumptions:
- 150M+ monthly active users
- 7M+ drivers online simultaneously
- Sub-second location updates
- Sub-2-second ETA recalculations
- 50+ frontend deploys per day
- Support for 1000+ cities with varying map tiles, pricing, regulations
- Multiple surfaces: Rider app, Driver app, Eats, Web, Admin dashboards
- Cross-platform state synchronization (iOS, Android, Web)
Frontend complexity drivers:
- Real-time location tracking with sub-second precision
- Bidirectional state synchronization (rider ↔ driver)
- Map rendering with dynamic overlays (ETAs, routes, surge zones)
- Offline-first architecture for driver app
- Multi-tenancy (Rides, Eats, Freight) sharing common UI infrastructure
- Localized UI for 50+ languages with RTL support
- Accessibility compliance across touch, voice, and screen readers
- Progressive enhancement for low-end devices (Android Go, feature phones in emerging markets)
The frontend isn't just a thin UI layer—it's a distributed system in itself, managing state across devices, handling network partitions, and ensuring consistency in an eventually-consistent world.
2. Frontend Challenges
2.1 Real-Time Location & ETA Updates
Problem: Displaying sub-second driver location updates without janky animations or battery drain.
Constraints:
- GPS updates arrive at 1-5 Hz from native layer
- Map tile rendering is expensive (20-50ms per frame)
- Smooth animations require 60fps (16.67ms frame budget)
- Location interpolation must account for road snapping, heading changes
- ETAs recalculate every 2-5 seconds based on traffic data
Engineering challenges:
- Throttling WebSocket messages without losing visual smoothness
- Requestanimationframe-based interpolation
- Differential updates (send only deltas, not full coordinates)
- Predicting next position using velocity vectors to pre-render tiles
2.2 Offline-First Driver Experience
Drivers operate in areas with poor connectivity: tunnels, rural zones, network congestion. A driver accepting a ride in a dead zone cannot afford to wait for network recovery.
Requirements:
- Queue ride acceptances offline, sync when reconnected
- Cache map tiles for last-known area (50-100MB)
- Local-first state management with eventual server reconciliation
- Conflict resolution (driver accepted ride A offline, server assigned ride B)
- Optimistic UI updates with rollback capability
2.3 Cross-Platform State Consistency
A rider books on iOS, cancels on Web, while driver sees updates on Android. All three clients must converge to the same state within seconds.
Challenges:
- Event ordering across distributed clients
- Causal consistency (cancellation must happen after booking)
- Idempotent event processing (same cancellation event delivered twice)
- Clock skew across devices (rider's phone is 30s ahead)
- Tombstone management (events older than 5 minutes can be purged)
2.4 Low-Latency Rendering on Budget Devices
Emerging markets dominate growth, but devices are constrained:
- Android Go devices: 1GB RAM, quad-core CPU
- Slow network: 3G with 500ms-2s RTT
- Battery constraints: Background processes killed aggressively
Optimization targets:
- Time to Interactive (TTI) < 5s on 3G
- INP < 200ms on low-end devices
- Memory budget < 150MB for JS heap
- Initial bundle < 300KB gzipped
2.5 Map Rendering Performance
Maps aren't just tiles—they're layered composites:
- Base layer (streets, buildings)
- Dynamic overlays (surge zones, heat maps)
- Animated markers (driver icons, destination pins)
- Route polylines with live traffic coloring
- Real-time UI elements (ETA badges, action buttons)
Bottlenecks:
- Canvas redraws are CPU-bound (10-30ms per frame)
- WebGL shader compilation (first render penalty: 200-500ms)
- Tile fetching from CDN (50-200ms per tile, 9-16 tiles per viewport)
- DOM thrashing when updating overlays (forced reflows)
2.6 Multi-Product Complexity
Uber Rides, Eats, Freight, and Transit share:
- Design system components (buttons, modals, inputs)
- Map infrastructure
- Real-time connection layer
- Analytics & observability
- Experimentation framework
But diverge on:
- Business logic (food orders vs ride requests)
- State machines (delivery flow vs pickup flow)
- Payment flows
- Regulatory requirements (food safety vs vehicle inspections)
Challenge: Shared infrastructure without tight coupling. How do you version a design system used by 50+ teams deploying independently?
3. High-Level Frontend Architecture
3.1 Rendering Strategy: Hybrid SSR + SPA
Uber.com (Rider Web):
Initial Load: SSR (Next.js) → Hydration → SPA transitions
└─ Landing pages: Static generation (ISR with 1h revalidation)
└─ Authenticated flows: Server-rendered with streaming
└─ In-ride experience: Full SPA with WebSocket connection
Why SSR?
- SEO for city landing pages (
uber.com/ride/seattle) - Faster FCP for first-time users (critical for conversion)
- Progressive enhancement (works without JS for basic flows)
Why not full SPA?
- Cold start TTI would be 4-7s (unacceptable for landing pages)
- SEO penalties for ride/eats discovery pages
Why not full SSR?
- Real-time ride tracking cannot be server-rendered
- Ride state updates are client-driven
- Map interactions require client-side JS
Tradeoff: Hydration mismatch bugs. Server-rendered map tiles can differ from client-rendered tiles if location data changes between SSR and hydration. Solution: Suppress hydration warnings for map container, treat it as client-only.
3.2 Monorepo Architecture
Uber uses a polyrepo-within-monorepo hybrid:
uber-web-monorepo/
├── packages/
│ ├── @uber/base-ui/ # Design system (50+ components)
│ ├── @uber/maps-web/ # Map rendering engine
│ ├── @uber/realtime-client/ # WebSocket abstraction
│ ├── @uber/analytics/ # Event tracking
│ ├── @uber/experimentation/ # A/B testing framework
│ ├── @uber/intl/ # i18n infrastructure
│ └── @uber/web-platform/ # Build tooling, CI/CD
├── apps/
│ ├── rider-web/ # Next.js app (Uber.com)
│ ├── driver-web/ # Driver dashboard
│ ├── eats-web/ # Uber Eats web
│ └── admin-tools/ # Internal ops dashboards
└── shared/
├── types/ # Shared TypeScript definitions
└── config/ # ESLint, Prettier, Tsconfig
Monorepo benefits:
- Atomic cross-package changes (update @uber/base-ui, propagate to all apps in one PR)
- Shared tooling (CI/CD, linting, testing)
- Codemods for large refactors (rename prop across 1000+ files)
Monorepo pain points:
- Build times (20-40 minutes for full monorepo build)
- Dependency graph complexity (circular deps between @uber/maps-web and @uber/realtime-client)
- Version skew (apps pin different versions of @uber/base-ui, leading to inconsistent UX)
Mitigation:
- Bazel for incremental builds (only rebuild affected packages)
- Module federation for runtime dependency sharing
- Automated dependency updates via Renovate
3.3 Microfrontend Architecture (Admin Tools)
Admin dashboards (ops tools, support dashboards, fraud detection) use module federation to allow independent deploys.
Admin Shell (Host)
├── Loads remote modules at runtime
├── Shared dependencies (React, base-ui)
└── Dynamic imports from CDN
Remote Modules (Microfrontends)
├── Support Dashboard (support-mfe.uber.com)
├── Fraud Detection (fraud-mfe.uber.com)
└── Driver Analytics (driver-analytics-mfe.uber.com)
Why microfrontends here but not rider/driver apps?
- Admin tools are low-traffic, high-complexity
- Different teams own different admin surfaces
- Deploy independence > Performance (admin users tolerate 1-2s load times)
Why NOT for rider/driver apps?
- Performance overhead (100-200ms per remote module load)
- Cache invalidation complexity (shared dependencies must match versions)
- Error boundaries become critical (one bad remote kills entire page)
Tradeoff: Increased operational complexity (50+ microfrontends to monitor) vs team autonomy.
3.4 Edge Rendering Strategy
Uber uses Cloudflare Workers + Next.js Edge Runtime for:
- City-specific landing pages (render
/ride/seattlewith Seattle-specific pricing) - Localized content (serve Spanish UI to
es-MXusers without full page reload) - A/B test bucketing at edge (avoid round-trip to origin)
Edge function example (pseudo-code):
// Cloudflare Worker
export default {
async fetch(request: Request) {
const geo = request.cf.city; // "Seattle"
const lang = request.headers.get('accept-language'); // "en-US"
// Edge KV lookup for city-specific config
const cityConfig = await CITY_KV.get(`config:${geo}`);
// Inject into HTML before streaming to client
const response = await fetch(request);
const modifiedHTML = response.body
.pipeThrough(new CityInjectorStream(cityConfig));
return new Response(modifiedHTML, {
headers: { 'cache-control': 'public, s-maxage=60' }
});
}
}
Why edge rendering?
- Reduced TTFB (50-100ms faster than origin rendering)
- Geo-specific optimizations (Asian users get Asian CDN map tiles)
Limitations:
- Cold start penalty (100-300ms if edge function isn't warm)
- Limited compute (50ms CPU time limit per request)
- No access to full Node.js APIs (filesystem, crypto, etc.)
4. Rendering Pipeline
4.1 Initial Page Load (Rider Web)
Timeline (P95, 3G network):
T+0ms → DNS lookup (20ms)
T+20ms → TCP handshake (50ms)
T+70ms → TLS negotiation (80ms)
T+150ms → HTML request sent
T+650ms → HTML received (500ms TTFB for SSR)
T+650ms → Browser parsing HTML
T+700ms → FCP (above-the-fold content rendered)
T+850ms → Critical CSS loaded (150ms)
T+1200ms → JS bundle downloaded (350ms)
T+1400ms → JS parsed & executed (200ms)
T+1600ms → React hydration complete
T+2000ms → TTI (page fully interactive)
Optimization: Streaming SSR
Next.js 13+ supports streaming HTML:
// app/ride/page.tsx
export default async function RidePage() {
return (
<Suspense fallback={<MapSkeleton />}>
<MapComponent /> {/* Streams after initial shell */}
</Suspense>
);
}
Before streaming:
- Server waits for map data → renders full HTML → sends to client (650ms TTFB)
After streaming:
- Server sends HTML shell immediately (200ms TTFB)
- Streams map content when ready (450ms later)
- Browser renders progressively (FCP at 250ms instead of 700ms)
Result: FCP improves by 450ms, perceived performance increases dramatically.
4.2 Code Splitting & Lazy Loading
Challenge: Initial JS bundle was 1.2MB gzipped (4-5s parse time on low-end devices).
Solution: Route-based + component-based splitting
// Route-based splitting (automatic with Next.js app router)
const RidePage = lazy(() => import('./routes/ride'));
const EatsPage = lazy(() => import('./routes/eats'));
// Component-based splitting (explicit for heavy components)
const MapComponent = lazy(() => import('@uber/maps-web'));
const PaymentModal = lazy(() => import('./PaymentModal'));
// Preload on hover (predictive prefetching)
function RideButton() {
return (
<button
onMouseEnter={() => import('./routes/ride')} // Prefetch on hover
onClick={handleRideClick}
>
Request Ride
</button>
);
}
Bundle breakdown after optimization:
Initial bundle: 180KB (critical path only)
Ride route: 120KB (loaded on /ride)
Maps library: 350KB (loaded when map initializes)
Payment flow: 80KB (loaded when user clicks "Pay")
Eats route: 150KB (loaded on /eats)
Tradeoff: Network waterfall complexity. User clicks "Request Ride" → 200ms to fetch route bundle → 150ms to parse → 100ms to render. Total: 450ms delay. Solution: Predictive prefetching based on user intent signals.
4.3 Hydration Strategy
Problem: Hydration mismatches caused 5-10% of users to see broken UI.
Root causes:
- Server renders at time T, client hydrates at T+2s (data changed)
- Locale-specific date formatting (server uses UTC, client uses local timezone)
- Random IDs generated on server differ from client-generated IDs
Solution: Selective hydration
// Suppress hydration for dynamic content
<div suppressHydrationWarning>
{new Date().toLocaleString()} {/* Always client-rendered */}
</div>
// Defer map rendering until after hydration
function MapContainer() {
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => setIsHydrated(true), []);
if (!isHydrated) return <MapSkeleton />;
return <Map />;
}
React 18 improvement: Selective Hydration
React 18's <Suspense> boundaries allow partial hydration:
<Suspense fallback={<Spinner />}>
<Comments /> {/* Low priority, hydrates last */}
</Suspense>
<Suspense fallback={<Spinner />}>
<MapComponent /> {/* High priority, hydrates first */}
</Suspense>
If user interacts with map before Comments hydrates, React prioritizes map hydration.
Result: TTI improved by 30% (from 2.8s to 1.9s on P95).
4.4 Prefetching Strategy
Uber uses multi-level prefetching:
- DNS prefetch (immediate)
<link rel="dns-prefetch" href="https://maps.googleapis.com" />
- Preconnect (on page load)
<link rel="preconnect" href="https://api.uber.com" />
- Route prefetch (on hover)
<Link href="/ride" prefetch="hover" />
- Data prefetch (predictive)
// Prefetch ride options when user types destination
function DestinationInput() {
const debouncedValue = useDebouncedValue(destination, 300);
useEffect(() => {
if (debouncedValue.length > 3) {
queryClient.prefetchQuery(['rideOptions', debouncedValue]);
}
}, [debouncedValue]);
}
- Map tile prefetch (viewport prediction)
// Prefetch map tiles for predicted user panning direction
function predictNextViewport(currentViewport, userVelocity) {
const predictedCenter = {
lat: currentViewport.lat + userVelocity.latPerSecond * 2,
lng: currentViewport.lng + userVelocity.lngPerSecond * 2
};
prefetchTilesForViewport(predictedCenter);
}
Tradeoff: Aggressive prefetching wastes bandwidth (20-30% of prefetched data never used). Solution: ML model predicts user intent (90% accuracy), only prefetch high-confidence predictions.
5. Real-Time Systems
5.1 WebSocket Architecture
Uber's real-time layer handles:
- Driver location updates (1-5 Hz)
- Ride status changes (requested → matched → arriving → in-progress → completed)
- ETA updates (every 5s)
- Chat messages
- Push notifications
Connection lifecycle:
Client Gateway Backend
| | |
|---WebSocket handshake----->| |
|<--Connection accepted------| |
| | |
|---Subscribe(ride:123)----->|---Subscribe Redis channel->|
| | |
| |<--Location update---------|
|<--Location update----------| |
| | |
|---Unsubscribe(ride:123)--->|---Unsubscribe Redis------>|
| | |
Protocol: Binary WebSocket with Protocol Buffers
Why not JSON?
- JSON:
{"type":"location","lat":37.7749,"lng":-122.4194,"heading":120}(68 bytes) - Protobuf:
\x08\x01\x12\x08@B\x0F...(22 bytes)
70% size reduction → Lower bandwidth → Faster transmission on 3G.
Message types:
message RealtimeMessage {
oneof payload {
LocationUpdate location = 1;
RideStatusUpdate ride_status = 2;
ETAUpdate eta = 3;
ChatMessage chat = 4;
}
}
message LocationUpdate {
string ride_id = 1;
double lat = 2;
double lng = 3;
int32 heading = 4;
int64 timestamp = 5;
}
5.2 Connection Management
Challenge: Mobile networks are unstable. WebSocket disconnects every 30-120s.
Reconnection strategy:
class RealtimeClient {
private reconnectAttempts = 0;
private maxReconnectDelay = 30_000; // 30s
connect() {
this.ws = new WebSocket(this.url);
this.ws.onclose = () => {
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
);
setTimeout(() => this.connect(), delay);
this.reconnectAttempts++;
};
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.resubscribeAll(); // Resubscribe to active channels
};
}
private resubscribeAll() {
// Re-subscribe to all active rides/channels
for (const channel of this.activeChannels) {
this.send({ type: 'subscribe', channel });
}
}
}
Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped).
Why not reconnect immediately?
- Server may be overwhelmed (thundering herd problem)
- Battery drain from constant reconnection attempts
Why cap at 30s?
- Beyond 30s, user likely closed app or lost interest
5.3 Fallback: HTTP Polling
In regions with poor WebSocket support (corporate firewalls, restrictive proxies), Uber falls back to long polling:
async function longPoll(rideId: string) {
while (this.isActive) {
try {
const response = await fetch(`/api/rides/${rideId}/updates`, {
headers: { 'X-Last-Event-ID': this.lastEventId }
});
const updates = await response.json();
for (const update of updates) {
this.handleUpdate(update);
this.lastEventId = update.id;
}
} catch (error) {
await sleep(5000); // Wait 5s before retrying
}
}
}
Server-side (pseudo-code):
app.get('/api/rides/:rideId/updates', async (req, res) => {
const lastEventId = req.headers['x-last-event-id'];
// Wait up to 30s for new events
const updates = await waitForUpdates(req.params.rideId, lastEventId, {
timeout: 30_000
});
res.json(updates);
});
Tradeoff: Long polling uses 10x more server resources than WebSockets (1 thread per connection). Solution: Only use in <5% of cases where WebSocket fails.
5.4 Presence System
Challenge: Show "Driver is nearby" status with sub-second accuracy.
Naive approach:
// Driver sends location every second
setInterval(() => {
ws.send({ type: 'location', lat, lng });
}, 1000);
Problem: 1M active drivers × 1 message/second × 100 bytes/message = 100MB/s uplink bandwidth.
Optimized approach: Differential updates + dead reckoning
class LocationTracker {
private lastSentLocation: Location;
private lastSentHeading: number;
maybeUpdateLocation(currentLocation: Location) {
const distanceMoved = haversine(this.lastSentLocation, currentLocation);
const headingChanged = Math.abs(currentLocation.heading - this.lastSentHeading);
// Only send update if moved >10m OR heading changed >15°
if (distanceMoved > 10 || headingChanged > 15) {
this.sendLocationUpdate(currentLocation);
this.lastSentLocation = currentLocation;
this.lastSentHeading = currentLocation.heading;
}
}
}
Client-side interpolation:
function interpolateLocation(lastKnown: Location, velocity: Velocity, elapsedMs: number) {
return {
lat: lastKnown.lat + velocity.latPerMs * elapsedMs,
lng: lastKnown.lng + velocity.lngPerMs * elapsedMs
};
}
function animateMarker() {
const elapsed = Date.now() - this.lastUpdateTime;
const interpolated = interpolateLocation(this.lastLocation, this.velocity, elapsed);
this.marker.setPosition(interpolated);
requestAnimationFrame(() => this.animateMarker());
}
Result: Bandwidth reduced by 85% while maintaining smooth 60fps animations.
6. Frontend Performance Engineering
6.1 Core Web Vitals Optimization
Target metrics (P75):
- LCP (Largest Contentful Paint): <2.5s
- INP (Interaction to Next Paint): <200ms
- CLS (Cumulative Layout Shift): <0.1
LCP optimization:
Uber's LCP element: Hero map on ride request screen.
Before optimization (LCP: 3.8s):
T+0ms → HTML loaded
T+800ms → JS parsed
T+1200ms → Map SDK loaded
T+1800ms → Map tiles fetched
T+3800ms → Map rendered (LCP)
After optimization (LCP: 1.9s):
- Preload critical resources:
<link rel="preload" href="/map-sdk.js" as="script" />
<link rel="preload" href="https://tiles.uber.com/base/0/0/0.png" as="image" />
- Inline map SDK critical path:
<script>
// Inline 10KB of map initialization code
window.__MAP_SDK__ = { /* critical SDK code */ };
</script>
- Lazy load non-critical map features:
// Load traffic overlay only after map renders
mapInstance.on('load', () => {
import('./map-overlays/traffic').then(mod => mod.init(mapInstance));
});
- SSR map skeleton:
// Server-rendered skeleton eliminates CLS
<div style={{ width: '100%', height: '400px', background: '#eee' }}>
<MapSkeleton /> {/* Static placeholder */}
</div>
Result: LCP reduced from 3.8s → 1.9s (50% improvement).
6.2 INP Optimization
Problem: Clicking "Request Ride" button had 450ms delay before visual feedback.
Root cause:
function handleRequestRide() {
// Synchronous work blocking main thread
const rideOptions = calculateRideOptions(origin, destination, preferences); // 120ms
const pricing = calculatePricing(rideOptions); // 80ms
const availableDrivers = filterDriversByDistance(drivers, origin); // 150ms
dispatch(requestRide({ rideOptions, pricing, availableDrivers })); // 100ms
// Total: 450ms of blocking JS execution
}
Solution: Offload to Web Worker
// Main thread
function handleRequestRide() {
// Immediate visual feedback
setButtonState('loading');
// Offload computation to worker
worker.postMessage({ type: 'calculateRideOptions', origin, destination });
}
// Web Worker (ride-options-worker.ts)
self.onmessage = (e) => {
if (e.data.type === 'calculateRideOptions') {
const result = {
rideOptions: calculateRideOptions(e.data.origin, e.data.destination),
pricing: calculatePricing(...),
availableDrivers: filterDriversByDistance(...)
};
self.postMessage({ type: 'rideOptionsReady', result });
}
};
Result: INP reduced from 450ms → 80ms (82% improvement). Main thread only handles state update, computation happens in parallel.
Tradeoff: Worker setup overhead (50ms) and message serialization cost (10-20ms). Only worth it for >100ms computations.
6.3 React Rendering Optimization
Problem: Updating driver location 5x/second caused entire ride screen to re-render.
Before optimization:
function RideScreen() {
const ride = useRide(rideId); // Re-renders on any ride field change
return (
<div>
<MapComponent location={ride.driverLocation} /> {/* Re-renders 5x/sec */}
<RideDetails ride={ride} /> {/* Unnecessary re-render */}
<ChatWidget rideId={rideId} /> {/* Unnecessary re-render */}
</div>
);
}
After optimization:
// Split state to prevent cascading re-renders
function RideScreen() {
return (
<div>
<DriverLocationMap rideId={rideId} /> {/* Only re-renders on location change */}
<RideDetails rideId={rideId} /> {/* Independent subscription */}
<ChatWidget rideId={rideId} /> {/* Independent subscription */}
</div>
);
}
function DriverLocationMap({ rideId }) {
// Granular subscription to only driver location
const location = useSelector(state => state.rides[rideId]?.driverLocation);
return <MapComponent location={location} />;
}
function RideDetails({ rideId }) {
// Subscription to ride metadata (status, ETA, fare)
const ride = useSelector(state => {
const r = state.rides[rideId];
return { status: r.status, eta: r.eta, fare: r.fare };
}, shallowEqual); // Prevent re-render if object reference changes but values are same
return <div>{/* Ride details UI */}</div>;
}
Result: Re-render count reduced from 300/min → 30/min (90% reduction).
Additional optimization: useMemo for expensive computations
function RoutePolyline({ waypoints }) {
// Recompute polyline only if waypoints change
const polylineCoordinates = useMemo(
() => computePolyline(waypoints), // 50ms computation
[waypoints]
);
return <Polyline coordinates={polylineCoordinates} />;
}
6.4 Memory Optimization
Problem: Map component leaked memory, causing crashes after 10-15 minutes of active use.
Root cause:
class MapComponent extends React.Component {
componentDidMount() {
this.map = new MapSDK(this.containerRef);
// Event listener never removed
this.map.on('move', this.handleMapMove);
}
componentWillUnmount() {
// Missing cleanup!
// this.map.off('move', this.handleMapMove);
// this.map.destroy();
}
}
Memory leak sources:
- Event listeners not removed
- WebSocket subscriptions not closed
- Timers/intervals not cleared
- DOM nodes retained in closures
Solution: Comprehensive cleanup
function MapComponent({ rideId }) {
const mapRef = useRef<MapSDK>();
useEffect(() => {
const map = new MapSDK(containerRef.current);
mapRef.current = map;
const handleMove = () => { /* ... */ };
map.on('move', handleMove);
return () => {
// Cleanup on unmount
map.off('move', handleMove);
map.destroy();
mapRef.current = null;
};
}, []);
// WebSocket cleanup
useEffect(() => {
const ws = subscribeToRide(rideId);
return () => ws.unsubscribe();
}, [rideId]);
return <div ref={containerRef} />;
}
Monitoring memory leaks:
// Track heap size growth
if (process.env.NODE_ENV === 'development') {
setInterval(() => {
if (performance.memory) {
console.log('Heap used:', performance.memory.usedJSHeapSize / 1048576, 'MB');
}
}, 5000);
}
Result: Memory usage stabilized at 80-120MB (down from 300-500MB before crash).
6.5 Bundle Optimization
Problem: Initial JS bundle was too large (1.2MB gzipped).
Optimizations applied:
- Remove unused code (Tree-shaking)
// Before: Imported entire lodash library (70KB)
import _ from 'lodash';
// After: Import only used functions (5KB)
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
- Replace heavy libraries
// Before: Moment.js (67KB)
import moment from 'moment';
// After: date-fns (10KB for used functions only)
import { format, differenceInMinutes } from 'date-fns';
- Dynamic imports for heavy features
// Before: All payment methods loaded upfront
import { CreditCard, PayPal, ApplePay, Venmo } from './payment-methods';
// After: Load on-demand
const CreditCard = lazy(() => import('./payment-methods/CreditCard'));
const PayPal = lazy(() => import('./payment-methods/PayPal'));
- Optimize images
// Use WebP with JPEG fallback
<picture>
<source srcSet="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero" />
</picture>
// Responsive images
<img
srcSet="
/map-thumbnail-320w.webp 320w,
/map-thumbnail-640w.webp 640w,
/map-thumbnail-1280w.webp 1280w
"
sizes="(max-width: 600px) 320px, (max-width: 1200px) 640px, 1280px"
src="/map-thumbnail-640w.jpg"
/>
Final bundle sizes:
Initial: 180KB (critical path)
Ride route: 120KB
Maps: 350KB (lazy loaded)
Payments: 80KB (lazy loaded)
Total: 730KB (down from 1200KB)
6.6 Caching Strategy
Multi-level caching:
Client Request
│
├─> 1. Memory Cache (React Query)
│ └─ Hit: Return immediately (<1ms)
│
├─> 2. IndexedDB Cache
│ └─ Hit: Return in 5-20ms
│
├─> 3. Service Worker Cache
│ └─ Hit: Return in 10-50ms
│
├─> 4. CDN Cache (Cloudflare)
│ └─ Hit: Return in 50-200ms
│
└─> 5. Origin Server
└─ Miss: Return in 200-1000ms
React Query configuration:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
// Optimistic updates
function useRequestRide() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: requestRideAPI,
onMutate: async (newRide) => {
// Cancel outgoing refetches
await queryClient.cancelQueries(['rides']);
// Snapshot previous value
const previousRides = queryClient.getQueryData(['rides']);
// Optimistically update cache
queryClient.setQueryData(['rides'], (old) => [...old, newRide]);
return { previousRides };
},
onError: (err, newRide, context) => {
// Rollback on error
queryClient.setQueryData(['rides'], context.previousRides);
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries(['rides']);
},
});
}
Service Worker caching:
// service-worker.ts
const CACHE_NAME = 'uber-v1.2.3';
const STATIC_ASSETS = [
'/',
'/styles.css',
'/main.js',
'/map-sdk.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) return response; // Cache hit
return fetch(event.request).then((response) => {
// Cache dynamic assets
if (event.request.url.includes('/api/')) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
});
})
);
});
7. Frontend Data Layer
7.1 GraphQL + REST Hybrid
Uber uses GraphQL for reads, REST for writes.
Why GraphQL for reads?
- Fetch nested data in one request (ride + driver + vehicle + route)
- Avoid overfetching (rider app doesn't need driver's SSN)
- Schema-driven development (frontend and backend teams agree on schema)
Why REST for writes?
- Better idempotency semantics (POST with idempotency key)
- Easier rate limiting (limit POST /rides to 5/min)
- Simpler error handling (HTTP status codes map cleanly)
Example query:
query GetRideDetails($rideId: ID!) {
ride(id: $rideId) {
id
status
fare {
amount
currency
}
driver {
name
rating
vehicle {
make
model
licensePlate
}
location {
lat
lng
}
}
route {
waypoints {
lat
lng
}
distance
duration
}
}
}
Equivalent REST calls (6 requests):
GET /rides/123
GET /drivers/456
GET /vehicles/789
GET /locations/456
GET /routes/123
GET /fares/123
GraphQL advantage: 1 request vs 6 requests = 500ms saved on 3G.
7.2 Cache Invalidation Strategy
Problem: Rider cancels ride on iOS, driver still sees "Ride active" on Android.
Solution: Event-driven cache invalidation
// WebSocket listener invalidates cache on events
ws.on('message', (event) => {
if (event.type === 'RIDE_CANCELLED') {
queryClient.invalidateQueries(['ride', event.rideId]);
queryClient.invalidateQueries(['activeRides']);
}
});
// Polling fallback for regions without WebSocket
useQuery(['ride', rideId], fetchRide, {
refetchInterval: (data) => {
// Poll every 5s if ride is active
if (data?.status === 'active') return 5000;
// Stop polling if ride completed
return false;
}
});
7.3 Optimistic Updates with Conflict Resolution
Scenario: Driver accepts ride offline, server already assigned it to another driver.
Conflict resolution:
function useAcceptRide() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: acceptRideAPI,
onMutate: async (rideId) => {
// Optimistic update
queryClient.setQueryData(['ride', rideId], (old) => ({
...old,
status: 'accepted',
driver: currentDriver,
}));
},
onError: (error, rideId, context) => {
if (error.code === 'RIDE_ALREADY_ACCEPTED') {
// Show conflict UI
showToast('This ride was already accepted by another driver');
// Refetch latest state
queryClient.invalidateQueries(['ride', rideId]);
} else {
// Rollback optimistic update
queryClient.setQueryData(['ride', rideId], context.previousRide);
}
},
});
}
Last-write-wins with vector clocks:
interface RideState {
id: string;
status: string;
version: number; // Incremented on every update
vectorClock: Record<string, number>; // client_id → version
}
function mergeRideStates(local: RideState, remote: RideState): RideState {
// If remote version is higher, use remote
if (remote.version > local.version) return remote;
// If local version is higher, keep local (will sync to server later)
if (local.version > remote.version) return local;
// If versions are equal, use vector clocks to detect concurrent writes
const localClock = local.vectorClock[local.clientId] || 0;
const remoteClock = remote.vectorClock[remote.clientId] || 0;
// Choose state with higher clock value
return localClock > remoteClock ? local : remote;
}
7.4 Pagination & Virtualization
Problem: Loading 10,000 past rides causes browser to freeze.
Solution: Virtualized infinite scrolling
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
function RideHistoryList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['rideHistory'],
queryFn: ({ pageParam = 0 }) => fetchRides({ offset: pageParam, limit: 50 }),
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length * 50 : undefined;
},
});
const allRides = data?.pages.flatMap((page) => page.rides) ?? [];
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: allRides.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Each ride row is ~80px
overscan: 5, // Render 5 items above/below viewport
});
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
if (
lastItem &&
lastItem.index >= allRides.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), allRides.length, hasNextPage, isFetchingNextPage]);
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const ride = allRides[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<RideRow ride={ride} />
</div>
);
})}
</div>
</div>
);
}
Performance improvement:
- Before: Render 10,000 DOM nodes → 3-5s freeze + 500MB memory
- After: Render 10-20 visible nodes → 60fps smooth scrolling + 80MB memory
8. Scaling Frontend Teams
8.1 Monorepo Tooling
Uber's monorepo uses Bazel for incremental builds.
Problem without Bazel:
- Full monorepo build: 40 minutes
- Single package change: Rebuild everything (40 minutes)
With Bazel:
- Full build (cold): 40 minutes
- Single package change: Rebuild only affected packages (2-5 minutes)
Bazel dependency graph:
@uber/rider-web
├─> @uber/base-ui
│ ├─> react
│ └─> @uber/theme
├─> @uber/maps-web
│ └─> @uber/realtime-client
└─> @uber/analytics
When @uber/theme changes, Bazel rebuilds:
@uber/theme@uber/base-ui(depends on theme)@uber/rider-web(depends on base-ui)
Not rebuilt: @uber/maps-web, @uber/analytics (no dependency on theme).
CI/CD optimization:
# .github/workflows/ci.yml
name: CI
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# Fetch Bazel cache from previous builds
- uses: actions/cache@v3
with:
path: ~/.cache/bazel
key: bazel-${{ hashFiles('WORKSPACE', 'BUILD.bazel') }}
# Only build affected packages
- run: bazel build $(bazel query 'kind(.*_library, rdeps(//..., set(//packages/base-ui/...) ))')
# Only test affected packages
- run: bazel test $(bazel query 'kind(.*_test, rdeps(//..., set(//packages/base-ui/...) ))')
Result: CI time reduced from 40min → 8min for typical PRs.
8.2 Feature Flags & Experimentation
Uber runs 1000+ A/B experiments concurrently.
Feature flag architecture:
// Feature flag configuration (fetched at app load)
interface FeatureFlags {
new_checkout_flow: boolean;
ai_price_prediction: boolean;
map_3d_buildings: boolean;
}
// Client-side evaluation
function useFeatureFlag(flag: keyof FeatureFlags): boolean {
const userId = useUserId();
const flags = useFeatureFlags();
// Deterministic bucketing (same user always gets same variant)
const hash = murmurhash3(userId + flag);
const bucket = hash % 100;
// Gradual rollout: 0-25% enabled
if (flags[flag] && bucket < 25) return true;
return false;
}
// Usage
function CheckoutButton() {
const newCheckoutEnabled = useFeatureFlag('new_checkout_flow');
if (newCheckoutEnabled) {
return <NewCheckoutFlow />;
}
return <LegacyCheckoutFlow />;
}
Edge-based bucketing (faster than client-side):
// Cloudflare Worker
export default {
async fetch(request: Request) {
const userId = request.headers.get('X-User-ID');
// Bucket user at edge
const experiments = bucketUser(userId);
// Inject experiments into HTML
const html = await fetch(request);
const modifiedHTML = injectExperiments(html, experiments);
return new Response(modifiedHTML);
}
}
A/B test metrics collection:
// Track conversion events
function trackCheckoutCompleted(experimentId: string, variant: string) {
analytics.track('checkout_completed', {
experiment_id: experimentId,
variant: variant,
user_id: userId,
revenue: totalAmount,
});
}
// Analysis (backend)
SELECT
variant,
COUNT(*) as conversions,
AVG(revenue) as avg_revenue
FROM events
WHERE experiment_id = 'new_checkout_flow'
AND event_type = 'checkout_completed'
GROUP BY variant;
-- Results:
-- control: 1000 conversions, $15.20 avg revenue
-- treatment: 1200 conversions, $16.50 avg revenue
-- Winner: treatment (+20% conversions, +8.6% revenue)
8.3 Design System Versioning
Challenge: 50 teams use @uber/base-ui, but need to upgrade independently.
Solution: Semantic versioning + coexistence
// Package.json
{
"dependencies": {
"@uber/base-ui": "^3.0.0" // Rider app
}
}
// Driver app still on v2
{
"dependencies": {
"@uber/base-ui": "^2.14.0"
}
}
Breaking change migration:
// v2: Old API
<Button color="primary" />
// v3: New API (breaking change)
<Button variant="primary" />
// Migration codemod (automatically updates code)
// npx jscodeshift -t codemods/button-color-to-variant.ts src/
export default function transform(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
root
.find(j.JSXElement, {
openingElement: {
name: { name: 'Button' },
attributes: [{ name: { name: 'color' } }],
},
})
.forEach((path) => {
j(path)
.find(j.JSXAttribute, { name: { name: 'color' } })
.forEach((attr) => {
attr.node.name.name = 'variant';
});
});
return root.toSource();
}
Gradual rollout:
- Week 1: Publish v3 as
@uber/base-ui@3.0.0-beta - Week 2-4: Teams test beta, report issues
- Week 5: Publish stable v3
- Week 6-10: Teams migrate (codemod automates 80%)
- Week 11: Deprecate v2, remove in 6 months
8.4 Independent Deployments
Problem: 50 teams sharing monorepo, but deployments block each other.
Solution: Micro-deployments with Vercel/Netlify
Monorepo structure:
├── apps/
│ ├── rider-web/ → Deployed to rider.uber.com
│ ├── driver-web/ → Deployed to driver.uber.com
│ └── eats-web/ → Deployed to ubereats.com
Each app deploys independently:
- Rider team merges PR → Auto-deploy to rider.uber.com
- Driver team merges PR → Auto-deploy to driver.uber.com
- No coordination needed
Shared dependency updates:
# Renovate config (auto-updates dependencies)
{
"extends": ["config:base"],
"packageRules": [
{
"matchPackagePatterns": ["@uber/base-ui"],
"groupName": "uber base-ui",
"automerge": true,
"automergeType": "pr",
"major": {
"automerge": false // Require manual review for breaking changes
}
}
]
}
9. Mobile Frontend Strategy
9.1 React Native vs Native
Uber's strategy:
- Native (Swift/Kotlin): Rider & Driver apps core (maps, real-time tracking)
- React Native: Eats ordering flow, secondary features, admin tools
Why native for core apps?
- Maps performance: Native rendering 2-3x faster than RN
- Battery efficiency: Native location tracking uses 40% less battery
- Platform APIs: Tight integration with iOS/Android SDKs
Why React Native for Eats?
- Faster iteration (weekly releases vs monthly for native)
- Code sharing with web (50-70% shared business logic)
- Easier hiring (JS engineers > Kotlin/Swift engineers)
9.2 Shared Business Logic (Web + Mobile)
Architecture:
┌───────────────────────────────────────┐
│ Presentation Layer (Platform) │
│ ┌─────────┬──────────┬─────────────┐ │
│ │ Web │ iOS │ Android │ │
│ │ (React) │ (Swift) │ (Kotlin) │ │
│ └────┬────┴─────┬────┴──────┬──────┘ │
│ │ │ │ │
│ └──────────┼───────────┘ │
│ │ │
│ ┌────────────▼─────────────┐ │
│ │ Business Logic (JS) │ │
│ │ - Pricing calculation │ │
│ │ - Validation rules │ │
│ │ - State machines │ │
│ │ - API client │ │
│ └──────────────────────────┘ │
└───────────────────────────────────────┘
Example: Shared pricing logic
// @uber/pricing-engine (runs on Web, iOS, Android via JSCore/Hermes)
export function calculateRidePrice(params: RidePricingParams): RidePrice {
const { distance, duration, surgeMultiplier, baseRate } = params;
const distanceCharge = distance * baseRate.perKm;
const timeCharge = duration * baseRate.perMinute;
const surgeFee = (distanceCharge + timeCharge) * (surgeMultiplier - 1);
const subtotal = distanceCharge + timeCharge + surgeFee;
const tax = subtotal * 0.08; // 8% tax
const total = subtotal + tax;
return { distanceCharge, timeCharge, surgeFee, tax, total };
}
// iOS (Swift)
import JavaScriptCore
let context = JSContext()!
context.evaluateScript(pricingEngineJS)
let result = context.evaluateScript("calculateRidePrice(\(params))")
// Android (Kotlin)
import com.facebook.react.bridge.ReactContext
val reactContext = ReactContext(applicationContext)
val result = reactContext.getJSModule(PricingEngine::class.java)
.calculateRidePrice(params)
// Web (TypeScript)
import { calculateRidePrice } from '@uber/pricing-engine';
const result = calculateRidePrice(params);
Result: 70% code reuse across platforms, consistent pricing logic.
9.3 Offline-First Architecture (Driver App)
Requirements:
- Driver must accept rides in tunnels (no network)
- Queue actions, sync when network returns
- Handle conflicts (server assigned ride to different driver)
Architecture:
// Offline action queue
interface OfflineAction {
id: string;
type: 'ACCEPT_RIDE' | 'START_RIDE' | 'COMPLETE_RIDE';
payload: any;
timestamp: number;
retries: number;
}
class OfflineQueue {
private queue: OfflineAction[] = [];
enqueue(action: OfflineAction) {
this.queue.push(action);
AsyncStorage.setItem('offline_queue', JSON.stringify(this.queue));
// Try to sync immediately
this.sync();
}
async sync() {
if (!navigator.onLine) return;
while (this.queue.length > 0) {
const action = this.queue[0];
try {
await this.executeAction(action);
this.queue.shift(); // Remove from queue on success
} catch (error) {
if (error.code === 'CONFLICT') {
// Server rejected action, remove from queue
this.queue.shift();
this.handleConflict(action, error);
} else {
// Network error, retry later
action.retries++;
if (action.retries > 5) {
this.queue.shift(); // Give up after 5 retries
this.handleFailure(action);
}
break;
}
}
}
AsyncStorage.setItem('offline_queue', JSON.stringify(this.queue));
}
private async executeAction(action: OfflineAction) {
switch (action.type) {
case 'ACCEPT_RIDE':
return await api.acceptRide(action.payload.rideId);
// ...
}
}
}
// React hook
function useOfflineAction() {
const queue = useOfflineQueue();
return useMutation({
mutationFn: async (action: OfflineAction) => {
if (navigator.onLine) {
// Online: Execute immediately
return await executeAction(action);
} else {
// Offline: Queue for later
queue.enqueue(action);
return { queued: true };
}
},
});
}
Result: 95% of offline actions successfully synced when network returns.
10. AI in Frontend Architecture
10.1 AI-Powered Price Predictions
Feature: Show "Prices are lower than usual" prediction before requesting ride.
Architecture:
User enters destination
│
├─> Call prediction API
│ └─> ML model predicts surge likelihood
│
├─> Show prediction UI
│ └─> "Prices are 15% lower than usual"
│
└─> Track user action
└─> Did user request ride? (training signal)
Frontend implementation:
function usePricePrediction(origin: Location, destination: Location) {
return useQuery({
queryKey: ['pricePrediction', origin, destination],
queryFn: async () => {
const response = await fetch('/api/ml/price-prediction', {
method: 'POST',
body: JSON.stringify({ origin, destination, timestamp: Date.now() }),
});
return response.json(); // { prediction: "lower", confidence: 0.87 }
},
staleTime: 60_000, // Cache for 1 minute
enabled: !!origin && !!destination,
});
}
function RideRequest() {
const { data: prediction } = usePricePrediction(origin, destination);
return (
<div>
{prediction?.prediction === 'lower' && (
<Banner variant="success">
Prices are {prediction.percentLower}% lower than usual
</Banner>
)}
<RequestRideButton />
</div>
);
}
Latency optimization:
- P50: 120ms (model inference on GPU)
- P95: 250ms
- P99: 400ms (cold start)
Fallback: If prediction API takes >500ms, hide UI (don't block ride request).
10.2 AI Search (Uber Eats)
Feature: Natural language search ("spicy vegan tacos near me").
Traditional search:
SELECT * FROM restaurants
WHERE name ILIKE '%tacos%'
AND distance < 5000
ORDER BY rating DESC;
AI search (vector similarity):
// Generate embedding for query
const queryEmbedding = await fetch('/api/embeddings', {
method: 'POST',
body: JSON.stringify({ text: 'spicy vegan tacos near me' }),
});
// Vector similarity search
const results = await fetch('/api/search/vector', {
method: 'POST',
body: JSON.stringify({
embedding: queryEmbedding,
filters: { location: userLocation, radius: 5000 },
limit: 20,
}),
});
// Results ranked by semantic similarity:
// 1. "Spicy Plant-Based Taqueria" (0.92 similarity)
// 2. "Vegan Street Tacos" (0.89 similarity)
// 3. "Hot & Spicy Veggie Wraps" (0.78 similarity)
Frontend streaming UI:
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Restaurant[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const eventSource = new EventSource(`/api/search/stream?q=${query}`);
eventSource.onmessage = (event) => {
const newResult = JSON.parse(event.data);
setResults((prev) => [...prev, newResult]);
};
eventSource.addEventListener('done', () => {
setIsLoading(false);
eventSource.close();
});
return () => eventSource.close();
}, [query]);
return (
<div>
{results.map((restaurant) => (
<RestaurantCard key={restaurant.id} restaurant={restaurant} />
))}
{isLoading && <Spinner />}
</div>
);
}
Performance:
- Traditional search: 50ms (but poor relevance)
- AI search: 200ms (much better relevance)
Tradeoff: 4x slower but 3x higher click-through rate (users find what they want faster).
10.3 AI Copilot (Driver Assistant)
Feature: AI assistant helps drivers navigate, find parking, optimize earnings.
Streaming AI responses:
function DriverCopilot() {
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
async function sendMessage(prompt: string) {
setIsStreaming(true);
const response = await fetch('/api/copilot/chat', {
method: 'POST',
body: JSON.stringify({ prompt, context: driverContext }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader!.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Update UI with partial response
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, content: buffer }];
} else {
return [...prev, { role: 'assistant', content: buffer }];
}
});
}
setIsStreaming(false);
}
return (
<div>
<ChatMessages messages={messages} />
{isStreaming && <TypingIndicator />}
<ChatInput onSend={sendMessage} />
</div>
);
}
Token streaming latency:
- Time to first token: 200-400ms
- Tokens per second: 40-60
- Perceived latency: <500ms (user sees response almost immediately)
11. Security Architecture
11.1 Content Security Policy (CSP)
Challenge: Prevent XSS attacks while allowing maps, analytics, payment iframes.
CSP header:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}' https://maps.googleapis.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https://*.uber.com https://maps.googleapis.com;
connect-src 'self' wss://realtime.uber.com https://api.uber.com;
frame-src https://checkout.stripe.com;
font-src 'self' https://fonts.gstatic.com;
Nonce-based script loading:
// Server-rendered page
export default function Page() {
const nonce = generateNonce(); // Random 128-bit value
return (
<html>
<head>
<meta httpEquiv="Content-Security-Policy" content={`script-src 'nonce-${nonce}'`} />
<script nonce={nonce} src="/main.js" />
</head>
</html>
);
}
Why nonces over unsafe-inline?
unsafe-inline: Any<script>tag executes (XSS vulnerability)- Nonces: Only scripts with matching nonce execute (XSS mitigated)
11.2 Secure Token Storage
Problem: Where to store JWT tokens?
Options:
| Storage | Security | Persistence | Pros | Cons |
|---|---|---|---|---|
localStorage | ❌ Vulnerable to XSS | ✅ Survives page reload | Simple API | XSS = full compromise |
sessionStorage | ❌ Vulnerable to XSS | ❌ Cleared on tab close | Scoped to tab | Still vulnerable to XSS |
| Cookie (HttpOnly) | ✅ Not accessible via JS | ✅ Survives page reload | XSS-proof | Vulnerable to CSRF |
| In-memory | ✅ XSS-resistant | ❌ Cleared on page reload | Most secure | Poor UX (re-login on refresh) |
Uber's solution: HttpOnly cookie + CSRF token
// Server sets HttpOnly cookie
app.post('/auth/login', (req, res) => {
const token = generateJWT(user);
res.cookie('auth_token', token, {
httpOnly: true, // Not accessible via JS
secure: true, // Only sent over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.json({ success: true });
});
// Client sends cookie automatically
fetch('/api/rides', {
credentials: 'include', // Include cookies in request
});
CSRF protection:
// Server generates CSRF token
app.get('/api/csrf-token', (req, res) => {
const csrfToken = generateCSRFToken();
res.json({ csrfToken });
});
// Client includes CSRF token in requests
fetch('/api/rides', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
},
credentials: 'include',
});
// Server validates CSRF token
app.post('/api/rides', (req, res) => {
const csrfToken = req.headers['x-csrf-token'];
if (!validateCSRFToken(csrfToken)) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
// Process request
});
11.3 Rate Limiting & Bot Mitigation
Challenge: Prevent bots from scraping prices, spamming ride requests.
Rate limiting (per user):
// Client-side (enforced at edge)
const rateLimiter = new RateLimiter({
maxRequests: 10,
windowMs: 60_000, // 10 requests per minute
});
async function requestRide() {
if (!rateLimiter.tryAcquire()) {
throw new Error('Rate limit exceeded. Please try again in 1 minute.');
}
await fetch('/api/rides', { method: 'POST', ... });
}
// Server-side (backup enforcement)
app.post('/api/rides', async (req, res) => {
const userId = req.user.id;
const key = `rate_limit:${userId}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, 60); // 60 second window
}
if (current > 10) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
// Process request
});
Bot detection (Cloudflare Turnstile):
function RequestRideForm() {
const [turnstileToken, setTurnstileToken] = useState<string>();
return (
<form onSubmit={handleSubmit}>
{/* Turnstile widget (CAPTCHA-less bot detection) */}
<Turnstile
siteKey="0x4AAAAAAAAAABBBBcccccDDD"
onVerify={setTurnstileToken}
/>
<button type="submit" disabled={!turnstileToken}>
Request Ride
</button>
</form>
);
}
// Server validates token
app.post('/api/rides', async (req, res) => {
const { turnstileToken } = req.body;
const validation = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET,
response: turnstileToken,
}),
});
if (!validation.success) {
return res.status(403).json({ error: 'Bot detected' });
}
// Process request
});
12. Observability & Monitoring
12.1 Real User Monitoring (RUM)
Metrics tracked:
- Core Web Vitals (LCP, INP, CLS)
- Custom metrics (time to interactive map, ride request success rate)
- Error rates
- Network performance (TTFB, resource timing)
DataDog RUM integration:
import { datadogRum } from '@datadog/browser-rum';
datadogRum.init({
applicationId: 'abc123',
clientToken: 'def456',
site: 'datadoghq.com',
service: 'rider-web',
env: 'production',
version: '1.2.3',
sessionSampleRate: 100, // Sample 100% of sessions
sessionReplaySampleRate: 20, // Record 20% of sessions
trackInteractions: true,
trackResources: true,
trackLongTasks: true,
defaultPrivacyLevel: 'mask-user-input', // Mask PII
});
// Custom metric
datadogRum.addTiming('map_interactive', performance.now() - navigationStart);
// Track user actions
datadogRum.addAction('ride_requested', {
origin: rideRequest.origin,
destination: rideRequest.destination,
fare: rideRequest.estimatedFare,
});
Session replay:
Uber records 20% of sessions (sampled) to debug user-reported issues.
Example: User reports "Ride request button doesn't work."
- Search session replay for user ID
- Watch video replay of session
- See user clicked button, but JavaScript error prevented request
- Fix bug, deploy, verify fix in replay
12.2 Error Tracking (Sentry)
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: 'https://abc@o123.ingest.sentry.io/456',
environment: 'production',
release: '1.2.3',
integrations: [
new Sentry.BrowserTracing({
tracingOrigins: ['api.uber.com'],
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes
),
}),
new Sentry.Replay({
maskAllText: true,
blockAllMedia: true,
}),
],
tracesSampleRate: 0.1, // Sample 10% of transactions
replaysSessionSampleRate: 0.1, // Record 10% of sessions
replaysOnErrorSampleRate: 1.0, // Record 100% of error sessions
});
// Error boundary
function RideScreen() {
return (
<Sentry.ErrorBoundary
fallback={<ErrorFallback />}
onError={(error, errorInfo) => {
// Custom error handling
logErrorToAnalytics(error);
}}
>
<RideDetails />
<MapComponent />
</Sentry.ErrorBoundary>
);
}
Alerting:
# Sentry alert rule
rules:
- name: High error rate
conditions:
- event.count > 100
- event.timeframe = 5m
actions:
- PagerDuty: oncall-frontend
- Slack: #frontend-alerts
12.3 Distributed Tracing
Trace ride request across services:
User clicks "Request Ride"
│
├─> Frontend: POST /api/rides (trace_id: abc123)
│ └─> 200ms
│
├─> Gateway: POST /v1/rides (trace_id: abc123, span_id: def456)
│ └─> 180ms
│
├─> Ride Service: Create ride (trace_id: abc123, span_id: ghi789)
│ └─> 120ms
│
├─> Matching Service: Find driver (trace_id: abc123, span_id: jkl012)
│ └─> 90ms
│
└─> Notification Service: Notify driver (trace_id: abc123, span_id: mno345)
└─> 30ms
Frontend instrumentation:
import { trace, context } from '@opentelemetry/api';
async function requestRide(rideParams: RideParams) {
const tracer = trace.getTracer('rider-web');
return tracer.startActiveSpan('request_ride', async (span) => {
span.setAttribute('origin', rideParams.origin);
span.setAttribute('destination', rideParams.destination);
try {
const response = await fetch('/api/rides', {
method: 'POST',
headers: {
'X-Trace-ID': span.spanContext().traceId,
},
body: JSON.stringify(rideParams),
});
span.setStatus({ code: SpanStatusCode.OK });
return response.json();
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
throw error;
} finally {
span.end();
}
});
}
Tracing dashboard:
Request Ride (P95: 450ms)
├─ Frontend rendering: 50ms (11%)
├─ API request: 200ms (44%)
│ ├─ Gateway processing: 20ms
│ ├─ Ride service: 120ms (26%)
│ │ ├─ DB query: 40ms
│ │ └─ Validation: 80ms
│ └─ Matching service: 60ms (13%)
├─ WebSocket setup: 100ms (22%)
└─ Map rendering: 100ms (22%)
Insight: Matching service is slow. Optimize driver search algorithm.
13. Architecture Evolution
13.1 Phase 1: Monolithic SPA (2012-2015)
Architecture:
- Single React SPA
- All features in one bundle (2MB+)
- REST API
- Manual polling for updates
Pain points:
- Slow initial load (5-8s TTI)
- No SEO
- Difficult to scale teams (merge conflicts daily)
- No offline support
13.2 Phase 2: Microservices + Code Splitting (2016-2018)
Changes:
- Split into Rider, Driver, Eats apps
- Code splitting (route-based)
- GraphQL for data fetching
- WebSockets for real-time updates
Improvements:
- TTI reduced to 3-4s
- Teams could work independently
- Real-time updates improved UX
New pain points:
- GraphQL N+1 queries
- WebSocket connection management complexity
- Still no SEO
13.3 Phase 3: SSR + Edge Rendering (2019-2021)
Changes:
- Next.js for SSR
- Cloudflare Workers for edge rendering
- Service workers for offline support
- React Query for data layer
Improvements:
- TTI reduced to 2-3s
- SEO for landing pages
- Offline support for driver app
New pain points:
- Hydration mismatches
- Edge compute limits (50ms CPU time)
13.4 Phase 4: Streaming SSR + AI (2022-Present)
Changes:
- React 18 streaming SSR
- AI-powered search, predictions, copilot
- Module federation for admin tools
- Edge-based A/B testing
Improvements:
- FCP reduced to <1s
- AI features increased engagement 20-30%
- Teams fully autonomous with microfrontends
Current challenges:
- Managing 1000+ concurrent A/B experiments
- AI inference latency (200-400ms)
- Observability complexity (50+ services)
14. Tradeoffs & Engineering Decisions
14.1 Why Not Full Microfrontends?
Considered: Split Rider app into microfrontends (map, booking, payment, profile).
Why rejected:
- Performance overhead (100-200ms per remote module)
- Cache invalidation complexity
- Shared state management becomes nightmare (how does map MFE communicate with booking MFE?)
Decision: Monolith for rider/driver apps, microfrontends only for admin tools.
14.2 Why SSR over Pure SPA?
Tradeoff: SSR adds server-side complexity.
Why SSR wins:
- 50% faster FCP (1s vs 2s)
- SEO for city landing pages drives 30% of new users
- Progressive enhancement (works without JS for basic flows)
Downside: Hydration bugs, server costs (10% more compute).
14.3 Why GraphQL for Reads, REST for Writes?
Why not full GraphQL?
- GraphQL mutations have poor idempotency semantics
- Rate limiting is harder (can't limit by HTTP verb)
- Error handling is inconsistent (always 200 OK, errors in payload)
Why not full REST?
- Overfetching (fetch ride, then driver, then vehicle, then route)
- 6 round-trips instead of 1
Decision: Hybrid gives best of both worlds.
14.4 Why Edge Rendering?
Tradeoff: Edge functions have 50ms CPU limit, limited APIs.
Why edge wins for landing pages:
- 100ms faster TTFB (50ms vs 150ms)
- Geo-specific content (Seattle landing page different from NYC)
- A/B test bucketing at edge (no round-trip to origin)
Why NOT for ride tracking:
- Need full Node.js APIs (WebSocket, database connections)
- Need long-running connections (ride lasts 10-30 minutes)
15. Future Architecture
15.1 React Server Components
Current limitation: Client components must fetch data client-side.
With RSC:
// Server Component (runs on server, never sent to client)
async function RideDetails({ rideId }: { rideId: string }) {
// Fetch data directly on server
const ride = await db.rides.findUnique({ where: { id: rideId } });
return (
<div>
<h1>Ride to {ride.destination}</h1>
<DriverInfo driverId={ride.driverId} /> {/* Nested server component */}
<MapComponent location={ride.driverLocation} /> {/* Client component */}
</div>
);
}
Benefits:
- Zero-latency data fetching (no API request from client)
- Smaller bundle (server components not shipped to client)
- Better security (API keys stay on server)
Challenges:
- Cannot use hooks (
useState,useEffect) - Cannot use browser APIs (
window,localStorage) - Requires server infrastructure (edge functions may be too limited)
Timeline: Experimental adoption in 2026, production by 2027.
15.2 Islands Architecture (Partial Hydration)
Current problem: Hydrate entire page even if only map is interactive.
Islands approach:
// Static content (no hydration)
<Header />
<RideDetails />
// Interactive island (hydrated)
<Island>
<MapComponent />
</Island>
// Static content
<Footer />
Benefits:
- 80% less JS execution (only hydrate interactive parts)
- Faster TTI (1s instead of 2s)
Challenges:
- Complex build tooling
- Shared state between islands
Timeline: Proof of concept in 2026.
15.3 AI-Generated UI
Vision: Rider describes destination in natural language, AI generates custom UI.
Example:
User: "I need a ride to the airport, I have 3 large suitcases"
AI generates custom UI:
- Filters to XL vehicles only
- Shows luggage capacity for each option
- Highlights vehicles with ample trunk space
- Suggests booking 10 minutes early for loading time
Technical approach:
async function generateCustomUI(userPrompt: string, context: Context) {
const response = await fetch('/api/ai/generate-ui', {
method: 'POST',
body: JSON.stringify({ prompt: userPrompt, context }),
});
const { componentTree } = await response.json();
// componentTree = {
// type: 'RideOptions',
// props: { filters: { vehicleType: 'XL', minSeats: 4 } },
// children: [...]
// }
return <DynamicComponent tree={componentTree} />;
}
Challenges:
- Trust & safety (AI could generate malicious UI)
- Consistency (AI-generated UI may not match design system)
- Latency (2-5s to generate UI)
Timeline: Research phase, 2027-2028 for production.
15.4 WebGPU for Map Rendering
Current limitation: Canvas/WebGL rendering is CPU-bound.
With WebGPU:
- GPU-accelerated tile rendering
- 10x faster map updates
- Smooth 60fps with 1000+ markers
Timeline: Experimental in Chrome 113+, production by 2026.
Conclusion
Uber's frontend architecture is a testament to iterative evolution driven by real-world constraints: unreliable networks, low-end devices, global scale, and the need for real-time precision. The system balances performance, reliability, and developer velocity through:
- Hybrid rendering: SSR for landing pages, SPA for real-time tracking
- Real-time infrastructure: WebSockets with intelligent fallbacks
- Offensive performance optimization: Code splitting, prefetching, edge rendering
- Offline-first mobile architecture: Queue actions, sync when connected
- AI integration: Search, predictions, copilot—all with streaming UIs
- Scalable team structure: Monorepo with independent deploys, shared design system
- Comprehensive observability: RUM, distributed tracing, session replay
The architecture isn't perfect—hydration bugs, AI latency, and A/B test complexity remain challenges. But each tradeoff is intentional, each decision rooted in production constraints and measured outcomes.
The future points toward server components, partial hydration, and AI-generated UIs—but the fundamentals remain: prioritize user experience, measure everything, and iterate relentlessly.
Engineering is about tradeoffs. Uber's frontend architecture is a masterclass in making the right ones at global scale.
What did you think?