System Design
Part 6 of 7Zomato Frontend System Architecture: Engineering Food Delivery at 80M Monthly Users
Zomato Frontend System Architecture: Engineering Food Delivery at 80M Monthly Users
1. Product Overview
Zomato operates India's largest food delivery and restaurant discovery platform, serving 80M+ monthly active users with access to 200K+ restaurant partners across 1,000+ cities. But the frontend challenge isn't just displaying menus—it's orchestrating real-time order tracking across millions of concurrent deliveries, rendering complex menu customizations, handling peak-time surge (10x normal traffic during lunch/dinner), and delivering sub-second search across millions of dishes while a hungry user decides what to eat.
Scale assumptions:
- 80M+ monthly active users
- 200K+ restaurant partners
- 500K+ active delivery partners
- 2M+ daily orders (peak: 5M+ on weekends/festivals)
- 10M+ menu items with customizations
- 15M+ reviews and ratings
- 50M+ food photos
- Sub-2-second restaurant discovery
- Real-time tracking for millions of concurrent orders
- 3x traffic spike during lunch (12-2 PM) and dinner (7-10 PM)
Frontend complexity drivers:
- Menu rendering: Nested customizations (size → crust → toppings → extras) with price calculations
- Real-time tracking: Live map with delivery partner location updating every 3-5 seconds
- Restaurant availability: Dynamic open/close status, prep time, delivery radius
- Cart complexity: Multi-item, multi-customization, promo codes, delivery fees
- Search & discovery: Instant search across restaurants, dishes, cuisines with filters
- Peak handling: Graceful degradation during 10x traffic spikes
- Image-heavy UI: Optimized loading for food photos (appetite appeal is everything)
- Payment diversity: UPI, cards, wallets, COD, Zomato Credits, split payments
- Hyperlocal: Different restaurants, pricing, and availability per location
The frontend isn't just an ordering interface—it's a real-time logistics visualization system that must make hungry users convert in under 60 seconds while coordinating restaurant prep, delivery assignment, and live tracking.
2. Frontend Challenges
2.1 Real-Time Order Tracking
Challenge: Show live delivery partner location on map with smooth animations, accurate ETA updates, and order status transitions.
Data flow:
Delivery Partner App (GPS)
│ (every 3-5 seconds)
▼
Location Service
│
├─> Calculate ETA
│
├─> WebSocket broadcast to customer
│
└─> Update tracking UI
├─> Animate marker on map
├─> Update ETA display
└─> Show status transitions
Technical challenges:
- GPS updates arrive at 3-5 second intervals (battery optimization on partner app)
- Map marker must animate smoothly between updates (interpolation)
- ETA recalculates based on traffic, route changes, multi-order batching
- Handle GPS drift (delivery partner appears to teleport)
- Handle network disconnection (show last known position)
2.2 Complex Menu Customization
Example: Ordering a pizza involves nested decisions:
Pizza Margherita (₹299)
├─> Size (required)
│ ├─> Regular (+₹0)
│ ├─> Medium (+₹100)
│ └─> Large (+₹200)
│
├─> Crust (required)
│ ├─> Hand Tossed (+₹0)
│ ├─> Thin Crust (+₹30)
│ └─> Cheese Burst (+₹99)
│
├─> Extra Toppings (optional, max 5)
│ ├─> Mushrooms (+₹40)
│ ├─> Olives (+₹40)
│ ├─> Jalapeños (+₹30)
│ └─> ... (20+ options)
│
├─> Extra Cheese (optional)
│ └─> Add Extra Cheese (+₹50)
│
└─> Special Instructions (free text)
Challenges:
- Dynamic price calculation as options change
- Validation rules (required selections, max limits)
- Option dependencies (some toppings only available with certain crusts)
- Persisting customizations in cart
- Displaying selected options in order summary
2.3 Restaurant Availability Logic
A restaurant's availability depends on:
- Operating hours (may vary by day)
- Current load (too many orders → temporarily closed)
- Delivery partner availability in area
- User's distance from restaurant
- Restaurant-specific delivery radius
- Special closures (holidays, emergencies)
Frontend must display:
// Possible states
type RestaurantStatus =
| { status: 'OPEN'; prepTime: number; deliveryTime: number }
| { status: 'BUSY'; message: 'High demand, longer wait times' }
| { status: 'CLOSED'; reopensAt: Date }
| { status: 'OUTSIDE_DELIVERY_AREA'; distance: number }
| { status: 'NOT_ACCEPTING_ORDERS'; reason: string }
| { status: 'TEMPORARILY_UNAVAILABLE'; estimatedReturn: Date };
2.4 Search with Instant Results
User expectation: Start typing "bir" → see "Biryani" results within 200ms.
Search must query:
- Restaurant names (200K+)
- Dish names (10M+)
- Cuisine types (100+)
- Collection names ("Best Pizzas", "Healthy Options")
- Popular searches
Ranking factors:
- Relevance to query
- User's past orders (personalization)
- Restaurant rating
- Delivery time
- Current availability
- Promotional boost (ads)
2.5 Peak Time Performance
Traffic pattern:
▲ Traffic
5x │ ┌───┐
│ │ │
4x │ ┌───┤ │
│ │ │ │
3x │ ┌───┤ │ ├───┐
│ │ │ │ │ │
2x │───┤ │ │ │ ├───
│ │ │ │ │ │
1x │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴──▶ Time
8AM 12PM 2PM 7PM 10PM
Lunch Dinner
Challenges during peak:
- 3-5x normal API latency
- Image CDN under pressure
- WebSocket connection limits
- Cart operations must not fail
- Payment processing delays
Frontend must:
- Implement request queuing and retry
- Show skeleton loaders (perceived performance)
- Cache aggressively (restaurant data, menu data)
- Degrade gracefully (hide non-essential features)
2.6 Food Photography Optimization
Food photos drive conversion. A well-photographed dish can increase orders by 30%+.
Image challenges:
- 50M+ photos in catalog
- Multiple sizes needed (thumbnail, card, full-screen)
- Format optimization (WebP, AVIF where supported)
- Lazy loading without layout shift
- Blur placeholder for perceived speed
- User-uploaded photos (variable quality)
2.7 Cart Abandonment Prevention
Cart abandonment rate in food delivery: 60-70%
Reasons:
- High delivery fees revealed late
- Long delivery times
- Minimum order not met
- Payment failures
- App crashes during checkout
Frontend strategies:
- Show delivery fee upfront
- Persistent cart (survives app close)
- One-tap reorder from past orders
- Express checkout with saved payment
- Cart recovery notifications
3. High-Level Frontend Architecture
3.1 Platform Strategy: React Native with Native Modules
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ React Native (iOS & Android) │ │
│ │ ┌────────────┬─────────────┬────────────────────────┐ │ │
│ │ │ Home & │ Restaurant │ Cart & │ │ │
│ │ │ Discovery │ & Menu │ Checkout │ │ │
│ │ └────────────┴─────────────┴────────────────────────┘ │ │
│ │ ┌────────────┬─────────────┬────────────────────────┐ │ │
│ │ │ Order │ Search │ Profile & │ │ │
│ │ │ Tracking │ │ Settings │ │ │
│ │ └────────────┴─────────────┴────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Native Modules │ │
│ │ ┌──────────┬───────────┬────────────┬───────────────┐ │ │
│ │ │ Maps │ Payment │ Push │ Deep │ │ │
│ │ │ (GMaps) │ Gateway │ Notifs │ Linking │ │ │
│ │ └──────────┴───────────┴────────────┴───────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Shared Business Logic │ │
│ │ ┌────────────┬─────────────┬────────────────────────┐ │ │
│ │ │ Cart │ Order │ Location │ │ │
│ │ │ Engine │ State │ Service │ │ │
│ │ │ │ Machine │ │ │ │
│ │ └────────────┴─────────────┴────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Why React Native?
- 80%+ code sharing between iOS and Android
- Faster iteration than native (OTA updates via CodePush)
- Strong ecosystem for maps, payments, animations
- Hot reloading for rapid development
Native modules for:
- Maps: Native Google Maps for smooth 60fps tracking
- Payments: UPI intents, native payment SDKs
- Push notifications: FCM/APNS with rich content
- Location: Background location for delivery partners
3.2 Web Architecture (zomato.com)
Rendering strategy:
Route Strategy Reason
─────────────────────────────────────────────────────────────
/ SSR SEO + fast FCP
/city/restaurants SSR + ISR SEO + fresh data
/restaurant/:slug SSR SEO + OG tags
/restaurant/:id/menu CSR Interactive
/checkout CSR Authenticated
/order/:id/tracking CSR Real-time
/search CSR Dynamic
Next.js App Router structure:
app/
├── (public)/
│ ├── page.tsx # Home (SSR)
│ ├── [city]/
│ │ ├── page.tsx # City restaurants (SSR)
│ │ └── [cuisine]/
│ │ └── page.tsx # Cuisine filter (SSR)
│ │
│ └── restaurant/
│ └── [slug]/
│ ├── page.tsx # Restaurant page (SSR)
│ └── menu/
│ └── page.tsx # Menu (CSR)
│
├── (authenticated)/
│ ├── cart/
│ │ └── page.tsx # Cart (CSR)
│ ├── checkout/
│ │ └── page.tsx # Checkout (CSR)
│ └── orders/
│ ├── page.tsx # Order history (CSR)
│ └── [id]/
│ └── tracking/
│ └── page.tsx # Live tracking (CSR)
│
└── api/
├── search/
├── cart/
└── orders/
3.3 Design System: Zomato UI Kit
// Design tokens
const tokens = {
colors: {
primary: '#E23744', // Zomato Red
secondary: '#1C1C1C',
background: {
primary: '#FFFFFF',
secondary: '#F8F8F8',
tertiary: '#EFEFEF',
},
text: {
primary: '#1C1C1C',
secondary: '#696969',
tertiary: '#9C9C9C',
},
success: '#3AB757',
warning: '#FCCA1C',
error: '#E23744',
rating: {
excellent: '#3AB757', // 4.0+
good: '#9ACD32', // 3.5-4.0
average: '#FCCA1C', // 3.0-3.5
poor: '#E23744', // < 3.0
},
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
},
borderRadius: {
sm: 4,
md: 8,
lg: 12,
xl: 16,
pill: 999,
},
typography: {
h1: { fontSize: 28, fontWeight: 'bold', lineHeight: 34 },
h2: { fontSize: 22, fontWeight: 'bold', lineHeight: 28 },
h3: { fontSize: 18, fontWeight: '600', lineHeight: 24 },
body: { fontSize: 14, fontWeight: 'normal', lineHeight: 20 },
caption: { fontSize: 12, fontWeight: 'normal', lineHeight: 16 },
},
};
// Rating badge component
function RatingBadge({ rating, reviewCount }: { rating: number; reviewCount: number }) {
const getColor = (rating: number) => {
if (rating >= 4.0) return tokens.colors.rating.excellent;
if (rating >= 3.5) return tokens.colors.rating.good;
if (rating >= 3.0) return tokens.colors.rating.average;
return tokens.colors.rating.poor;
};
return (
<View style={[styles.badge, { backgroundColor: getColor(rating) }]}>
<Text style={styles.rating}>{rating.toFixed(1)}</Text>
<StarIcon size={12} color="white" />
</View>
);
}
3.4 State Management: Zustand + React Query
// Global cart state (Zustand)
interface CartState {
restaurantId: string | null;
restaurantName: string | null;
items: CartItem[];
appliedCoupon: Coupon | null;
deliveryAddress: Address | null;
deliveryInstructions: string;
// Actions
addItem: (item: MenuItem, customizations: Customization[], quantity: number) => void;
removeItem: (cartItemId: string) => void;
updateQuantity: (cartItemId: string, quantity: number) => void;
applyCoupon: (code: string) => Promise<void>;
removeCoupon: () => void;
clearCart: () => void;
setDeliveryAddress: (address: Address) => void;
}
const useCartStore = create<CartState>()(
persist(
(set, get) => ({
restaurantId: null,
restaurantName: null,
items: [],
appliedCoupon: null,
deliveryAddress: null,
deliveryInstructions: '',
addItem: (item, customizations, quantity) => {
const state = get();
// Check if switching restaurants
if (state.restaurantId && state.restaurantId !== item.restaurantId) {
// Show confirmation dialog
Alert.alert(
'Replace cart?',
`Your cart contains items from ${state.restaurantName}. Do you want to clear it and add items from ${item.restaurantName}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Replace',
onPress: () => {
set({
restaurantId: item.restaurantId,
restaurantName: item.restaurantName,
items: [createCartItem(item, customizations, quantity)],
appliedCoupon: null,
});
},
},
]
);
return;
}
// Check if same item with same customizations exists
const existingItemIndex = state.items.findIndex(
(cartItem) =>
cartItem.menuItemId === item.id &&
isCustomizationsEqual(cartItem.customizations, customizations)
);
if (existingItemIndex >= 0) {
// Update quantity
const newItems = [...state.items];
newItems[existingItemIndex].quantity += quantity;
set({ items: newItems });
} else {
// Add new item
set({
restaurantId: item.restaurantId,
restaurantName: item.restaurantName,
items: [...state.items, createCartItem(item, customizations, quantity)],
});
}
},
removeItem: (cartItemId) => {
const newItems = get().items.filter((item) => item.id !== cartItemId);
if (newItems.length === 0) {
set({
restaurantId: null,
restaurantName: null,
items: [],
appliedCoupon: null,
});
} else {
set({ items: newItems });
}
},
updateQuantity: (cartItemId, quantity) => {
if (quantity <= 0) {
get().removeItem(cartItemId);
return;
}
set({
items: get().items.map((item) =>
item.id === cartItemId ? { ...item, quantity } : item
),
});
},
applyCoupon: async (code) => {
const { items, restaurantId } = get();
const subtotal = calculateSubtotal(items);
const result = await api.validateCoupon(code, restaurantId, subtotal);
if (result.valid) {
set({ appliedCoupon: result.coupon });
} else {
throw new Error(result.message);
}
},
removeCoupon: () => set({ appliedCoupon: null }),
clearCart: () =>
set({
restaurantId: null,
restaurantName: null,
items: [],
appliedCoupon: null,
}),
setDeliveryAddress: (address) => set({ deliveryAddress: address }),
}),
{
name: 'zomato-cart',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// Server state for restaurant data (React Query)
function useRestaurant(restaurantId: string) {
return useQuery({
queryKey: ['restaurant', restaurantId],
queryFn: () => fetchRestaurant(restaurantId),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes
});
}
function useMenu(restaurantId: string) {
return useQuery({
queryKey: ['menu', restaurantId],
queryFn: () => fetchMenu(restaurantId),
staleTime: 10 * 60 * 1000, // 10 minutes (menus don't change often)
});
}
4. Menu Rendering Architecture
4.1 Menu Data Structure
interface Menu {
restaurantId: string;
categories: MenuCategory[];
popularItems: MenuItem[];
mustTryItems: MenuItem[];
}
interface MenuCategory {
id: string;
name: string;
description?: string;
items: MenuItem[];
isVeg?: boolean; // All items in category are veg
}
interface MenuItem {
id: string;
name: string;
description: string;
price: number;
discountedPrice?: number;
imageUrl?: string;
isVeg: boolean;
isAvailable: boolean;
unavailableReason?: string;
isBestseller: boolean;
rating?: number;
ratingCount?: number;
customizationGroups: CustomizationGroup[];
tags: string[]; // 'Spicy', 'Chef's Special', etc.
allergens: string[];
nutritionalInfo?: NutritionalInfo;
}
interface CustomizationGroup {
id: string;
name: string;
required: boolean;
minSelections: number;
maxSelections: number;
options: CustomizationOption[];
}
interface CustomizationOption {
id: string;
name: string;
price: number;
isDefault: boolean;
isVeg: boolean;
isAvailable: boolean;
}
4.2 Menu Screen with Sticky Category Navigation
function MenuScreen({ restaurantId }: { restaurantId: string }) {
const { data: menu, isLoading } = useMenu(restaurantId);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const scrollViewRef = useRef<ScrollView>(null);
const categoryRefs = useRef<Map<string, number>>(new Map());
// Track scroll position to update active category
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const scrollY = event.nativeEvent.contentOffset.y;
// Find which category is currently visible
let currentCategory = null;
for (const [categoryId, offset] of categoryRefs.current.entries()) {
if (scrollY >= offset - 100) {
currentCategory = categoryId;
}
}
if (currentCategory !== activeCategory) {
setActiveCategory(currentCategory);
}
}, [activeCategory]);
// Scroll to category when tapped
const scrollToCategory = (categoryId: string) => {
const offset = categoryRefs.current.get(categoryId);
if (offset !== undefined) {
scrollViewRef.current?.scrollTo({ y: offset, animated: true });
}
};
if (isLoading) return <MenuSkeleton />;
return (
<View style={styles.container}>
{/* Sticky category navigation */}
<View style={styles.categoryNav}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{menu?.categories.map((category) => (
<Pressable
key={category.id}
onPress={() => scrollToCategory(category.id)}
style={[
styles.categoryTab,
activeCategory === category.id && styles.categoryTabActive,
]}
>
<Text
style={[
styles.categoryTabText,
activeCategory === category.id && styles.categoryTabTextActive,
]}
>
{category.name}
</Text>
</Pressable>
))}
</ScrollView>
</View>
{/* Menu items */}
<ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
scrollEventThrottle={16}
>
{/* Popular section */}
{menu?.popularItems.length > 0 && (
<MenuSection
title="Popular"
items={menu.popularItems}
/>
)}
{/* Categories */}
{menu?.categories.map((category) => (
<View
key={category.id}
onLayout={(event) => {
categoryRefs.current.set(category.id, event.nativeEvent.layout.y);
}}
>
<MenuCategory category={category} />
</View>
))}
</ScrollView>
{/* Floating cart button */}
<CartFloatingButton />
</View>
);
}
4.3 Customization Bottom Sheet
function CustomizationSheet({
item,
onAddToCart,
onClose,
}: {
item: MenuItem;
onAddToCart: (customizations: SelectedCustomization[], quantity: number) => void;
onClose: () => void;
}) {
const [selections, setSelections] = useState<Map<string, Set<string>>>(new Map());
const [quantity, setQuantity] = useState(1);
const [specialInstructions, setSpecialInstructions] = useState('');
// Initialize with default selections
useEffect(() => {
const defaults = new Map<string, Set<string>>();
item.customizationGroups.forEach((group) => {
const defaultOptions = group.options
.filter((opt) => opt.isDefault)
.map((opt) => opt.id);
if (defaultOptions.length > 0) {
defaults.set(group.id, new Set(defaultOptions));
} else if (group.required) {
defaults.set(group.id, new Set());
}
});
setSelections(defaults);
}, [item]);
const handleOptionToggle = (groupId: string, optionId: string, group: CustomizationGroup) => {
setSelections((prev) => {
const newSelections = new Map(prev);
const groupSelections = new Set(prev.get(groupId) || []);
if (group.maxSelections === 1) {
// Radio behavior
groupSelections.clear();
groupSelections.add(optionId);
} else {
// Checkbox behavior
if (groupSelections.has(optionId)) {
groupSelections.delete(optionId);
} else {
if (groupSelections.size < group.maxSelections) {
groupSelections.add(optionId);
}
}
}
newSelections.set(groupId, groupSelections);
return newSelections;
});
};
const calculateTotalPrice = () => {
let total = item.discountedPrice ?? item.price;
selections.forEach((optionIds, groupId) => {
const group = item.customizationGroups.find((g) => g.id === groupId);
if (!group) return;
optionIds.forEach((optionId) => {
const option = group.options.find((o) => o.id === optionId);
if (option) {
total += option.price;
}
});
});
return total * quantity;
};
const isValid = () => {
return item.customizationGroups.every((group) => {
if (!group.required) return true;
const groupSelections = selections.get(group.id);
if (!groupSelections) return false;
return groupSelections.size >= group.minSelections;
});
};
const handleAddToCart = () => {
const customizations: SelectedCustomization[] = [];
selections.forEach((optionIds, groupId) => {
const group = item.customizationGroups.find((g) => g.id === groupId);
if (!group) return;
optionIds.forEach((optionId) => {
const option = group.options.find((o) => o.id === optionId);
if (option) {
customizations.push({
groupId,
groupName: group.name,
optionId,
optionName: option.name,
price: option.price,
});
}
});
});
onAddToCart(customizations, quantity);
onClose();
};
return (
<BottomSheet snapPoints={['70%', '90%']}>
<ScrollView style={styles.sheetContent}>
{/* Item header */}
<View style={styles.itemHeader}>
<VegIndicator isVeg={item.isVeg} />
<Text style={styles.itemName}>{item.name}</Text>
<Text style={styles.itemDescription}>{item.description}</Text>
</View>
{/* Customization groups */}
{item.customizationGroups.map((group) => (
<View key={group.id} style={styles.customizationGroup}>
<View style={styles.groupHeader}>
<Text style={styles.groupName}>{group.name}</Text>
{group.required && <Text style={styles.required}>Required</Text>}
{group.maxSelections > 1 && (
<Text style={styles.maxLabel}>Select up to {group.maxSelections}</Text>
)}
</View>
{group.options.map((option) => {
const isSelected = selections.get(group.id)?.has(option.id);
return (
<Pressable
key={option.id}
onPress={() => handleOptionToggle(group.id, option.id, group)}
disabled={!option.isAvailable}
style={[
styles.optionRow,
!option.isAvailable && styles.optionDisabled,
]}
>
<View style={styles.optionLeft}>
<VegIndicator isVeg={option.isVeg} size="small" />
<Text style={styles.optionName}>{option.name}</Text>
{!option.isAvailable && (
<Text style={styles.unavailable}>Unavailable</Text>
)}
</View>
<View style={styles.optionRight}>
{option.price > 0 && (
<Text style={styles.optionPrice}>+₹{option.price}</Text>
)}
{group.maxSelections === 1 ? (
<RadioButton selected={isSelected} />
) : (
<Checkbox checked={isSelected} />
)}
</View>
</Pressable>
);
})}
</View>
))}
{/* Special instructions */}
<View style={styles.instructionsSection}>
<Text style={styles.instructionsLabel}>Special Instructions</Text>
<TextInput
value={specialInstructions}
onChangeText={setSpecialInstructions}
placeholder="e.g., Less spicy, no onions"
multiline
style={styles.instructionsInput}
/>
</View>
</ScrollView>
{/* Footer */}
<View style={styles.sheetFooter}>
<QuantitySelector value={quantity} onChange={setQuantity} />
<Button
onPress={handleAddToCart}
disabled={!isValid()}
style={styles.addButton}
>
<Text style={styles.addButtonText}>
Add Item - ₹{calculateTotalPrice()}
</Text>
</Button>
</View>
</BottomSheet>
);
}
4.4 Veg/Non-Veg Filter
function MenuVegFilter({ value, onChange }: {
value: 'all' | 'veg' | 'nonveg';
onChange: (value: 'all' | 'veg' | 'nonveg') => void;
}) {
return (
<View style={styles.vegFilter}>
<Pressable
onPress={() => onChange(value === 'veg' ? 'all' : 'veg')}
style={[styles.filterButton, value === 'veg' && styles.filterActive]}
>
<VegIndicator isVeg={true} />
<Text style={styles.filterText}>Veg</Text>
</Pressable>
<Pressable
onPress={() => onChange(value === 'nonveg' ? 'all' : 'nonveg')}
style={[styles.filterButton, value === 'nonveg' && styles.filterActive]}
>
<VegIndicator isVeg={false} />
<Text style={styles.filterText}>Non-Veg</Text>
</Pressable>
</View>
);
}
function VegIndicator({ isVeg, size = 'medium' }: { isVeg: boolean; size?: 'small' | 'medium' }) {
const sizeStyles = {
small: { width: 14, height: 14, borderRadius: 2 },
medium: { width: 18, height: 18, borderRadius: 3 },
};
return (
<View
style={[
styles.vegIndicator,
sizeStyles[size],
{ borderColor: isVeg ? '#0F8A0F' : '#E23744' },
]}
>
<View
style={[
styles.vegDot,
{ backgroundColor: isVeg ? '#0F8A0F' : '#E23744' },
]}
/>
</View>
);
}
5. Real-Time Order Tracking
5.1 Order Status State Machine
type OrderStatus =
| 'PLACED'
| 'CONFIRMED'
| 'PREPARING'
| 'READY_FOR_PICKUP'
| 'PICKED_UP'
| 'ON_THE_WAY'
| 'ARRIVING'
| 'DELIVERED'
| 'CANCELLED';
interface OrderTrackingState {
orderId: string;
status: OrderStatus;
restaurant: {
name: string;
phone: string;
location: LatLng;
};
deliveryPartner: {
name: string;
phone: string;
photo: string;
rating: number;
vehicleNumber: string;
location: LatLng | null;
} | null;
estimatedDeliveryTime: Date;
timeline: OrderTimelineEvent[];
liveLocation: LatLng | null;
}
interface OrderTimelineEvent {
status: OrderStatus;
timestamp: Date;
message: string;
}
const ORDER_STATUS_CONFIG: Record<OrderStatus, {
title: string;
description: string;
icon: string;
color: string;
}> = {
PLACED: {
title: 'Order Placed',
description: 'Waiting for restaurant confirmation',
icon: 'receipt',
color: '#9C9C9C',
},
CONFIRMED: {
title: 'Order Confirmed',
description: 'Restaurant is preparing your order',
icon: 'check-circle',
color: '#3AB757',
},
PREPARING: {
title: 'Preparing',
description: 'Your food is being prepared',
icon: 'restaurant',
color: '#FCCA1C',
},
READY_FOR_PICKUP: {
title: 'Ready for Pickup',
description: 'Delivery partner on the way to restaurant',
icon: 'package',
color: '#FCCA1C',
},
PICKED_UP: {
title: 'Picked Up',
description: 'On the way to you',
icon: 'bike',
color: '#E23744',
},
ON_THE_WAY: {
title: 'On the Way',
description: 'Your delivery partner is heading to you',
icon: 'navigation',
color: '#E23744',
},
ARRIVING: {
title: 'Arriving Soon',
description: 'Almost there!',
icon: 'location',
color: '#3AB757',
},
DELIVERED: {
title: 'Delivered',
description: 'Enjoy your meal!',
icon: 'check-double',
color: '#3AB757',
},
CANCELLED: {
title: 'Cancelled',
description: 'Order was cancelled',
icon: 'x-circle',
color: '#E23744',
},
};
5.2 Live Tracking Map
function OrderTrackingMap({ order }: { order: OrderTrackingState }) {
const mapRef = useRef<MapView>(null);
const [deliveryLocation, setDeliveryLocation] = useState<LatLng | null>(
order.deliveryPartner?.location || null
);
const animatedLocation = useRef(new Animated.ValueXY()).current;
const wsClient = useWebSocket();
// Subscribe to real-time location updates
useEffect(() => {
if (!order.deliveryPartner) return;
const unsubscribe = wsClient.subscribe(
`order:${order.orderId}:location`,
(data: { location: LatLng; heading: number; speed: number }) => {
// Animate marker to new position
animateMarker(deliveryLocation, data.location);
setDeliveryLocation(data.location);
}
);
return unsubscribe;
}, [order.orderId, order.deliveryPartner]);
// Smooth marker animation
const animateMarker = (from: LatLng | null, to: LatLng) => {
if (!from) {
animatedLocation.setValue({ x: to.latitude, y: to.longitude });
return;
}
Animated.timing(animatedLocation, {
toValue: { x: to.latitude, y: to.longitude },
duration: 1000, // Animate over 1 second
easing: Easing.linear,
useNativeDriver: false,
}).start();
};
// Fit map to show all markers
useEffect(() => {
if (!mapRef.current) return;
const coordinates = [order.restaurant.location];
if (deliveryLocation) {
coordinates.push(deliveryLocation);
}
mapRef.current.fitToCoordinates(coordinates, {
edgePadding: { top: 50, right: 50, bottom: 150, left: 50 },
animated: true,
});
}, [deliveryLocation]);
return (
<View style={styles.mapContainer}>
<MapView
ref={mapRef}
style={styles.map}
initialRegion={{
latitude: order.restaurant.location.latitude,
longitude: order.restaurant.location.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
>
{/* Restaurant marker */}
<Marker
coordinate={order.restaurant.location}
anchor={{ x: 0.5, y: 1 }}
>
<RestaurantMarker name={order.restaurant.name} />
</Marker>
{/* Delivery partner marker (animated) */}
{deliveryLocation && (
<Marker.Animated
coordinate={{
latitude: animatedLocation.x,
longitude: animatedLocation.y,
}}
anchor={{ x: 0.5, y: 0.5 }}
>
<DeliveryPartnerMarker
photo={order.deliveryPartner?.photo}
heading={order.deliveryPartner?.heading}
/>
</Marker.Animated>
)}
{/* Route polyline */}
<RoutePolyline
origin={order.restaurant.location}
destination={deliveryLocation}
/>
</MapView>
{/* ETA overlay */}
<View style={styles.etaOverlay}>
<Text style={styles.etaText}>
Arriving in {formatETA(order.estimatedDeliveryTime)}
</Text>
</View>
</View>
);
}
function DeliveryPartnerMarker({ photo, heading }: { photo?: string; heading?: number }) {
return (
<View style={styles.deliveryMarker}>
<Image
source={{ uri: photo }}
style={[
styles.deliveryPhoto,
heading && { transform: [{ rotate: `${heading}deg` }] },
]}
/>
<View style={styles.markerPulse} />
</View>
);
}
5.3 Order Status Timeline
function OrderTimeline({ order }: { order: OrderTrackingState }) {
const statusSteps: OrderStatus[] = [
'PLACED',
'CONFIRMED',
'PREPARING',
'PICKED_UP',
'ON_THE_WAY',
'DELIVERED',
];
const currentIndex = statusSteps.indexOf(order.status);
return (
<View style={styles.timeline}>
{statusSteps.map((status, index) => {
const config = ORDER_STATUS_CONFIG[status];
const isCompleted = index < currentIndex;
const isCurrent = index === currentIndex;
const event = order.timeline.find((e) => e.status === status);
return (
<View key={status} style={styles.timelineStep}>
{/* Connector line */}
{index > 0 && (
<View
style={[
styles.connector,
isCompleted && styles.connectorCompleted,
]}
/>
)}
{/* Status dot */}
<View
style={[
styles.statusDot,
isCompleted && { backgroundColor: '#3AB757' },
isCurrent && { backgroundColor: config.color },
]}
>
{(isCompleted || isCurrent) && (
<Icon name={config.icon} size={16} color="white" />
)}
</View>
{/* Status info */}
<View style={styles.statusInfo}>
<Text
style={[
styles.statusTitle,
(isCompleted || isCurrent) && styles.statusTitleActive,
]}
>
{config.title}
</Text>
{isCurrent && (
<Text style={styles.statusDescription}>{config.description}</Text>
)}
{event && (
<Text style={styles.statusTime}>
{formatTime(event.timestamp)}
</Text>
)}
</View>
</View>
);
})}
</View>
);
}
5.4 Contact Delivery Partner
function DeliveryPartnerCard({ partner, orderId }: {
partner: DeliveryPartner;
orderId: string;
}) {
const handleCall = () => {
// Use masked calling (partner's number not exposed)
Linking.openURL(`tel:${partner.maskedPhone}`);
};
const handleChat = () => {
navigation.navigate('Chat', { orderId, partnerId: partner.id });
};
return (
<View style={styles.partnerCard}>
<Image source={{ uri: partner.photo }} style={styles.partnerPhoto} />
<View style={styles.partnerInfo}>
<Text style={styles.partnerName}>{partner.name}</Text>
<View style={styles.partnerRating}>
<StarIcon size={14} color="#FCCA1C" />
<Text style={styles.ratingText}>{partner.rating.toFixed(1)}</Text>
</View>
<Text style={styles.vehicleNumber}>{partner.vehicleNumber}</Text>
</View>
<View style={styles.partnerActions}>
<Pressable onPress={handleCall} style={styles.actionButton}>
<PhoneIcon size={24} color="#1C1C1C" />
</Pressable>
<Pressable onPress={handleChat} style={styles.actionButton}>
<ChatIcon size={24} color="#1C1C1C" />
</Pressable>
</View>
</View>
);
}
6. Search & Discovery
6.1 Instant Search with Debouncing
function SearchScreen() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
const [recentSearches, setRecentSearches] = useRecentSearches();
const location = useUserLocation();
const { data: results, isLoading } = useQuery({
queryKey: ['search', debouncedQuery, location],
queryFn: () => searchRestaurantsAndDishes(debouncedQuery, location),
enabled: debouncedQuery.length >= 2,
});
const { data: trending } = useQuery({
queryKey: ['trending', location],
queryFn: () => getTrendingSearches(location),
staleTime: 10 * 60 * 1000, // 10 minutes
});
const handleSearch = (searchTerm: string) => {
setQuery(searchTerm);
addToRecentSearches(searchTerm);
};
return (
<View style={styles.searchScreen}>
{/* Search input */}
<View style={styles.searchHeader}>
<SearchIcon size={20} color="#9C9C9C" />
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search for restaurant, cuisine or a dish"
style={styles.searchInput}
autoFocus
/>
{query.length > 0 && (
<Pressable onPress={() => setQuery('')}>
<CloseIcon size={20} color="#9C9C9C" />
</Pressable>
)}
</View>
{/* Results or suggestions */}
<ScrollView style={styles.searchContent}>
{query.length === 0 ? (
<>
{/* Recent searches */}
{recentSearches.length > 0 && (
<Section title="Recent Searches">
{recentSearches.map((search) => (
<RecentSearchItem
key={search}
term={search}
onPress={() => handleSearch(search)}
onRemove={() => removeFromRecent(search)}
/>
))}
</Section>
)}
{/* Trending */}
<Section title="Trending Now">
{trending?.map((item) => (
<TrendingItem
key={item.term}
item={item}
onPress={() => handleSearch(item.term)}
/>
))}
</Section>
{/* Popular cuisines */}
<Section title="Popular Cuisines">
<CuisineGrid onSelect={(cuisine) => handleSearch(cuisine)} />
</Section>
</>
) : isLoading ? (
<SearchSkeleton />
) : (
<>
{/* Dishes */}
{results?.dishes.length > 0 && (
<Section title="Dishes">
{results.dishes.map((dish) => (
<DishSearchResult key={dish.id} dish={dish} />
))}
</Section>
)}
{/* Restaurants */}
{results?.restaurants.length > 0 && (
<Section title="Restaurants">
{results.restaurants.map((restaurant) => (
<RestaurantSearchResult
key={restaurant.id}
restaurant={restaurant}
/>
))}
</Section>
)}
{/* No results */}
{results?.dishes.length === 0 && results?.restaurants.length === 0 && (
<NoResults query={query} />
)}
</>
)}
</ScrollView>
</View>
);
}
6.2 Filters
interface SearchFilters {
sortBy: 'relevance' | 'rating' | 'deliveryTime' | 'costLowToHigh' | 'costHighToLow';
cuisines: string[];
dietary: ('veg' | 'nonveg' | 'egg')[];
rating: number | null; // Min rating (e.g., 4.0)
deliveryTime: number | null; // Max delivery time in minutes
costForTwo: [number, number] | null; // Price range
offers: boolean; // Has offers
newlyAdded: boolean; // New restaurants
pureVeg: boolean; // Pure veg restaurants only
}
function FiltersSheet({ filters, onChange }: {
filters: SearchFilters;
onChange: (filters: SearchFilters) => void;
}) {
const [localFilters, setLocalFilters] = useState(filters);
const updateFilter = <K extends keyof SearchFilters>(
key: K,
value: SearchFilters[K]
) => {
setLocalFilters((prev) => ({ ...prev, [key]: value }));
};
const applyFilters = () => {
onChange(localFilters);
closeSheet();
};
const clearAll = () => {
setLocalFilters(defaultFilters);
};
const activeFilterCount = countActiveFilters(localFilters);
return (
<BottomSheet snapPoints={['80%']}>
<View style={styles.filtersHeader}>
<Text style={styles.filtersTitle}>Filters</Text>
{activeFilterCount > 0 && (
<Pressable onPress={clearAll}>
<Text style={styles.clearAll}>Clear All ({activeFilterCount})</Text>
</Pressable>
)}
</View>
<ScrollView style={styles.filtersContent}>
{/* Sort */}
<FilterSection title="Sort By">
<RadioGroup
value={localFilters.sortBy}
onChange={(value) => updateFilter('sortBy', value)}
options={[
{ value: 'relevance', label: 'Relevance' },
{ value: 'rating', label: 'Rating' },
{ value: 'deliveryTime', label: 'Delivery Time' },
{ value: 'costLowToHigh', label: 'Cost: Low to High' },
{ value: 'costHighToLow', label: 'Cost: High to Low' },
]}
/>
</FilterSection>
{/* Quick filters */}
<FilterSection title="Quick Filters">
<View style={styles.quickFilters}>
<FilterChip
label="Pure Veg"
icon={<VegIndicator isVeg />}
selected={localFilters.pureVeg}
onPress={() => updateFilter('pureVeg', !localFilters.pureVeg)}
/>
<FilterChip
label="Offers"
icon={<OfferIcon />}
selected={localFilters.offers}
onPress={() => updateFilter('offers', !localFilters.offers)}
/>
<FilterChip
label="Rating 4.0+"
icon={<StarIcon />}
selected={localFilters.rating === 4.0}
onPress={() => updateFilter('rating', localFilters.rating === 4.0 ? null : 4.0)}
/>
</View>
</FilterSection>
{/* Cuisines */}
<FilterSection title="Cuisines">
<CuisineMultiSelect
selected={localFilters.cuisines}
onChange={(cuisines) => updateFilter('cuisines', cuisines)}
/>
</FilterSection>
{/* Delivery time */}
<FilterSection title="Max Delivery Time">
<Slider
value={localFilters.deliveryTime || 60}
minimumValue={15}
maximumValue={60}
step={5}
onValueChange={(value) => updateFilter('deliveryTime', value)}
/>
<Text style={styles.sliderLabel}>
{localFilters.deliveryTime ? `Under ${localFilters.deliveryTime} mins` : 'Any time'}
</Text>
</FilterSection>
{/* Price range */}
<FilterSection title="Cost for Two">
<PriceRangeSlider
value={localFilters.costForTwo || [0, 2000]}
onChange={(range) => updateFilter('costForTwo', range)}
/>
</FilterSection>
</ScrollView>
<View style={styles.filtersFooter}>
<Button variant="primary" onPress={applyFilters}>
Apply Filters
</Button>
</View>
</BottomSheet>
);
}
7. Checkout & Payments
7.1 Checkout Flow
type CheckoutStep = 'address' | 'payment' | 'confirm';
interface CheckoutState {
step: CheckoutStep;
address: Address | null;
paymentMethod: PaymentMethod | null;
tipAmount: number;
deliveryInstructions: string;
isProcessing: boolean;
error: string | null;
}
function CheckoutScreen() {
const cart = useCartStore();
const [state, setState] = useState<CheckoutState>({
step: 'address',
address: cart.deliveryAddress,
paymentMethod: null,
tipAmount: 0,
deliveryInstructions: '',
isProcessing: false,
error: null,
});
const { data: billDetails } = useBillCalculation({
items: cart.items,
coupon: cart.appliedCoupon,
tipAmount: state.tipAmount,
address: state.address,
});
const handlePlaceOrder = async () => {
setState((s) => ({ ...s, isProcessing: true, error: null }));
try {
// Create order
const order = await api.createOrder({
restaurantId: cart.restaurantId,
items: cart.items,
address: state.address,
paymentMethod: state.paymentMethod,
couponCode: cart.appliedCoupon?.code,
tipAmount: state.tipAmount,
deliveryInstructions: state.deliveryInstructions,
});
// Process payment
if (state.paymentMethod?.type !== 'COD') {
const paymentResult = await processPayment(order.id, state.paymentMethod);
if (!paymentResult.success) {
throw new Error(paymentResult.message);
}
}
// Clear cart and navigate to tracking
cart.clearCart();
navigation.replace('OrderTracking', { orderId: order.id });
} catch (error) {
setState((s) => ({ ...s, error: error.message, isProcessing: false }));
}
};
return (
<View style={styles.checkout}>
<ScrollView style={styles.checkoutContent}>
{/* Address section */}
<AddressSection
address={state.address}
onEdit={() => navigation.navigate('AddressSelection')}
instructions={state.deliveryInstructions}
onInstructionsChange={(text) =>
setState((s) => ({ ...s, deliveryInstructions: text }))
}
/>
{/* Order summary */}
<OrderSummary items={cart.items} />
{/* Tip section */}
<TipSection
amount={state.tipAmount}
onChange={(amount) => setState((s) => ({ ...s, tipAmount: amount }))}
/>
{/* Coupon section */}
<CouponSection
appliedCoupon={cart.appliedCoupon}
onApply={cart.applyCoupon}
onRemove={cart.removeCoupon}
/>
{/* Bill details */}
<BillDetails bill={billDetails} />
{/* Payment methods */}
<PaymentMethodSection
selected={state.paymentMethod}
onChange={(method) => setState((s) => ({ ...s, paymentMethod: method }))}
total={billDetails?.total}
/>
</ScrollView>
{/* Error message */}
{state.error && (
<View style={styles.errorBanner}>
<Text style={styles.errorText}>{state.error}</Text>
</View>
)}
{/* Place order button */}
<View style={styles.checkoutFooter}>
<Button
onPress={handlePlaceOrder}
disabled={!state.address || !state.paymentMethod || state.isProcessing}
loading={state.isProcessing}
style={styles.placeOrderButton}
>
<Text style={styles.placeOrderText}>
Place Order • ₹{billDetails?.total}
</Text>
</Button>
</View>
</View>
);
}
7.2 Bill Calculation
interface BillDetails {
itemTotal: number;
deliveryFee: number;
platformFee: number;
gstAndCharges: number;
packagingCharges: number;
tip: number;
discount: number;
totalSavings: number;
total: number;
breakdown: BillLineItem[];
}
function useBillCalculation(params: {
items: CartItem[];
coupon: Coupon | null;
tipAmount: number;
address: Address | null;
}) {
return useQuery({
queryKey: ['bill', params],
queryFn: async () => {
const { items, coupon, tipAmount, address } = params;
// Calculate item total
const itemTotal = items.reduce((sum, item) => {
const itemPrice = item.basePrice + item.customizationsTotal;
return sum + itemPrice * item.quantity;
}, 0);
// Get delivery fee based on distance
let deliveryFee = 0;
if (address) {
const distance = await calculateDistance(items[0].restaurantId, address);
deliveryFee = calculateDeliveryFee(distance, itemTotal);
}
// Calculate other charges
const platformFee = 5; // Fixed platform fee
const packagingCharges = items.length * 10; // Per item packaging
const gstAndCharges = (itemTotal + deliveryFee) * 0.05; // 5% GST
// Calculate discount
let discount = 0;
if (coupon) {
discount = calculateCouponDiscount(coupon, itemTotal);
}
// Calculate total
const total =
itemTotal +
deliveryFee +
platformFee +
packagingCharges +
gstAndCharges +
tipAmount -
discount;
return {
itemTotal,
deliveryFee,
platformFee,
packagingCharges,
gstAndCharges,
tip: tipAmount,
discount,
totalSavings: discount,
total: Math.round(total * 100) / 100,
breakdown: [
{ label: 'Item Total', amount: itemTotal },
{ label: 'Delivery Fee', amount: deliveryFee },
{ label: 'Platform Fee', amount: platformFee },
{ label: 'Packaging', amount: packagingCharges },
{ label: 'GST & Charges', amount: gstAndCharges },
...(tipAmount > 0 ? [{ label: 'Tip', amount: tipAmount }] : []),
...(discount > 0 ? [{ label: 'Discount', amount: -discount }] : []),
],
};
},
enabled: params.items.length > 0,
});
}
function BillDetails({ bill }: { bill: BillDetails | undefined }) {
if (!bill) return null;
return (
<View style={styles.billDetails}>
<Text style={styles.billTitle}>Bill Details</Text>
{bill.breakdown.map((item) => (
<View key={item.label} style={styles.billRow}>
<Text style={styles.billLabel}>{item.label}</Text>
<Text
style={[
styles.billAmount,
item.amount < 0 && styles.billDiscount,
]}
>
{item.amount < 0 ? '-' : ''}₹{Math.abs(item.amount).toFixed(2)}
</Text>
</View>
))}
<View style={styles.billDivider} />
<View style={styles.billRow}>
<Text style={styles.billTotal}>To Pay</Text>
<Text style={styles.billTotalAmount}>₹{bill.total.toFixed(2)}</Text>
</View>
{bill.totalSavings > 0 && (
<View style={styles.savingsBanner}>
<Text style={styles.savingsText}>
You saved ₹{bill.totalSavings.toFixed(2)} on this order!
</Text>
</View>
)}
</View>
);
}
7.3 Payment Integration
function PaymentMethodSection({
selected,
onChange,
total,
}: PaymentMethodSectionProps) {
const { data: savedMethods } = useSavedPaymentMethods();
const { data: walletBalance } = useWalletBalance();
return (
<View style={styles.paymentSection}>
<Text style={styles.sectionTitle}>Payment Method</Text>
{/* Zomato Credits */}
{walletBalance?.credits > 0 && (
<PaymentOption
icon={<ZomatoIcon />}
title="Zomato Credits"
subtitle={`Balance: ₹${walletBalance.credits}`}
selected={selected?.type === 'CREDITS'}
onSelect={() => onChange({ type: 'CREDITS' })}
disabled={walletBalance.credits < total}
/>
)}
{/* UPI */}
<PaymentOption
icon={<UPIIcon />}
title="UPI"
subtitle="Pay via any UPI app"
selected={selected?.type === 'UPI'}
onSelect={() => onChange({ type: 'UPI' })}
expandable
>
<UPIPaymentOptions
onSelect={(upiId) => onChange({ type: 'UPI', upiId })}
/>
</PaymentOption>
{/* Saved cards */}
{savedMethods?.cards.map((card) => (
<PaymentOption
key={card.id}
icon={<CardIcon brand={card.brand} />}
title={`${card.brand} •••• ${card.last4}`}
subtitle={`Expires ${card.expiryMonth}/${card.expiryYear}`}
selected={selected?.type === 'CARD' && selected.cardId === card.id}
onSelect={() => onChange({ type: 'CARD', cardId: card.id })}
/>
))}
{/* Add new card */}
<PaymentOption
icon={<AddCardIcon />}
title="Add New Card"
onSelect={() => navigation.navigate('AddCard')}
/>
{/* Wallets */}
<PaymentOption
icon={<WalletIcon />}
title="Wallets"
subtitle="Paytm, PhonePe, etc."
selected={selected?.type === 'WALLET'}
onSelect={() => onChange({ type: 'WALLET' })}
expandable
>
<WalletOptions
onSelect={(wallet) => onChange({ type: 'WALLET', wallet })}
/>
</PaymentOption>
{/* Cash on Delivery */}
<PaymentOption
icon={<CashIcon />}
title="Cash on Delivery"
subtitle="Pay when your order arrives"
selected={selected?.type === 'COD'}
onSelect={() => onChange({ type: 'COD' })}
/>
</View>
);
}
// UPI payment handling
async function processUPIPayment(orderId: string, upiId: string): Promise<PaymentResult> {
// Create UPI intent
const { upiLink, transactionId } = await api.createUPIPayment(orderId);
// Open UPI app
const canOpen = await Linking.canOpenURL(upiLink);
if (canOpen) {
await Linking.openURL(upiLink);
// Poll for payment status
return await pollPaymentStatus(transactionId);
} else {
throw new Error('No UPI app found');
}
}
async function pollPaymentStatus(transactionId: string): Promise<PaymentResult> {
const maxAttempts = 60;
const pollInterval = 3000; // 3 seconds
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const status = await api.getPaymentStatus(transactionId);
if (status.status === 'SUCCESS') {
return { success: true };
}
if (status.status === 'FAILED') {
return { success: false, message: status.message };
}
// Still pending, wait and retry
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
return { success: false, message: 'Payment timeout' };
}
8. Performance Engineering
8.1 Image Optimization
// Image optimization service
const ImageService = {
getOptimizedUrl(originalUrl: string, options: {
width: number;
height: number;
quality?: number;
format?: 'webp' | 'jpeg';
}): string {
const { width, height, quality = 80, format = 'webp' } = options;
// Use Zomato's image CDN with transformation parameters
const baseUrl = 'https://b.zmtcdn.com/images/transform';
const params = new URLSearchParams({
fit: 'cover',
w: width.toString(),
h: height.toString(),
q: quality.toString(),
f: format,
src: originalUrl,
});
return `${baseUrl}?${params}`;
},
// Preload critical images
preloadImages(urls: string[]): void {
urls.forEach((url) => {
Image.prefetch(url);
});
},
};
// Fast image component with blur placeholder
function FoodImage({
uri,
width,
height,
style,
}: {
uri: string;
width: number;
height: number;
style?: StyleProp<ImageStyle>;
}) {
const [isLoaded, setIsLoaded] = useState(false);
const optimizedUri = useMemo(
() => ImageService.getOptimizedUrl(uri, { width, height }),
[uri, width, height]
);
const blurUri = useMemo(
() => ImageService.getOptimizedUrl(uri, { width: 20, height: 20, quality: 20 }),
[uri]
);
return (
<View style={[{ width, height }, style]}>
{/* Blur placeholder */}
{!isLoaded && (
<Image
source={{ uri: blurUri }}
style={[StyleSheet.absoluteFill, styles.blurImage]}
blurRadius={20}
/>
)}
{/* Main image */}
<Image
source={{ uri: optimizedUri }}
style={[StyleSheet.absoluteFill, { opacity: isLoaded ? 1 : 0 }]}
onLoad={() => setIsLoaded(true)}
/>
</View>
);
}
8.2 List Virtualization
function RestaurantList({ restaurants }: { restaurants: Restaurant[] }) {
const renderItem = useCallback(({ item }: { item: Restaurant }) => (
<RestaurantCard restaurant={item} />
), []);
const keyExtractor = useCallback((item: Restaurant) => item.id, []);
const getItemLayout = useCallback(
(_: any, index: number) => ({
length: RESTAURANT_CARD_HEIGHT,
offset: RESTAURANT_CARD_HEIGHT * index,
index,
}),
[]
);
return (
<FlashList
data={restaurants}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={RESTAURANT_CARD_HEIGHT}
getItemLayout={getItemLayout}
removeClippedSubviews
maxToRenderPerBatch={10}
windowSize={5}
initialNumToRender={10}
/>
);
}
8.3 Network Resilience
// Request queue for poor network conditions
class RequestQueue {
private queue: QueuedRequest[] = [];
private isOnline = true;
constructor() {
NetInfo.addEventListener((state) => {
this.isOnline = state.isConnected ?? true;
if (this.isOnline) {
this.processQueue();
}
});
}
async request<T>(config: RequestConfig): Promise<T> {
if (!this.isOnline && config.canQueue) {
return this.queueRequest(config);
}
try {
return await this.executeRequest(config);
} catch (error) {
if (this.isRetryableError(error) && config.retries > 0) {
await this.delay(config.retryDelay);
return this.request({ ...config, retries: config.retries - 1 });
}
throw error;
}
}
private async executeRequest<T>(config: RequestConfig): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(config.url, {
...config.options,
signal: controller.signal,
});
clearTimeout(timeout);
return response.json();
} catch (error) {
clearTimeout(timeout);
throw error;
}
}
private isRetryableError(error: any): boolean {
return (
error.name === 'AbortError' ||
error.message.includes('Network request failed') ||
error.status >= 500
);
}
}
// Usage with React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
staleTime: 5 * 60 * 1000,
networkMode: 'offlineFirst', // Return cached data while offline
},
mutations: {
retry: 3,
networkMode: 'offlineFirst',
},
},
});
8.4 Peak Time Optimizations
// Graceful degradation during peak hours
function usePeakTimeOptimizations() {
const [isPeakTime, setIsPeakTime] = useState(false);
useEffect(() => {
const checkPeakTime = () => {
const hour = new Date().getHours();
const isPeak = (hour >= 12 && hour <= 14) || (hour >= 19 && hour <= 22);
setIsPeakTime(isPeak);
};
checkPeakTime();
const interval = setInterval(checkPeakTime, 60000); // Check every minute
return () => clearInterval(interval);
}, []);
return {
isPeakTime,
// Reduce image quality during peak
imageQuality: isPeakTime ? 60 : 80,
// Increase cache time during peak
staleTime: isPeakTime ? 10 * 60 * 1000 : 5 * 60 * 1000,
// Disable non-essential animations
enableAnimations: !isPeakTime,
// Reduce refresh frequency
refreshInterval: isPeakTime ? 30000 : 10000,
};
}
// Component using peak time optimizations
function RestaurantList() {
const { imageQuality, staleTime, enableAnimations } = usePeakTimeOptimizations();
const { data } = useQuery({
queryKey: ['restaurants'],
queryFn: fetchRestaurants,
staleTime,
});
return (
<View>
{data?.map((restaurant) => (
<RestaurantCard
key={restaurant.id}
restaurant={restaurant}
imageQuality={imageQuality}
animated={enableAnimations}
/>
))}
</View>
);
}
9. Push Notifications
9.1 Notification Types
type NotificationType =
| 'ORDER_CONFIRMED'
| 'ORDER_PREPARING'
| 'DELIVERY_PARTNER_ASSIGNED'
| 'ORDER_PICKED_UP'
| 'ORDER_ARRIVING'
| 'ORDER_DELIVERED'
| 'ORDER_CANCELLED'
| 'PROMO_OFFER'
| 'RESTAURANT_RECOMMENDATION'
| 'REORDER_SUGGESTION'
| 'RATING_REQUEST';
interface ZomatoNotification {
type: NotificationType;
title: string;
body: string;
data: Record<string, unknown>;
imageUrl?: string;
actionUrl?: string;
}
// Handle notification press
async function handleNotificationPress(notification: ZomatoNotification) {
switch (notification.type) {
case 'ORDER_CONFIRMED':
case 'ORDER_PREPARING':
case 'DELIVERY_PARTNER_ASSIGNED':
case 'ORDER_PICKED_UP':
case 'ORDER_ARRIVING':
// Navigate to order tracking
navigation.navigate('OrderTracking', {
orderId: notification.data.orderId,
});
break;
case 'ORDER_DELIVERED':
// Navigate to rating screen
navigation.navigate('RateOrder', {
orderId: notification.data.orderId,
});
break;
case 'PROMO_OFFER':
// Navigate to restaurant or collection
navigation.navigate('Restaurant', {
restaurantId: notification.data.restaurantId,
});
break;
case 'REORDER_SUGGESTION':
// Navigate to restaurant with pre-filled cart
await prefillCartFromOrder(notification.data.orderId);
navigation.navigate('Cart');
break;
default:
// Navigate to home
navigation.navigate('Home');
}
}
9.2 Rich Notifications
// Firebase Cloud Messaging setup
import messaging from '@react-native-firebase/messaging';
async function setupNotifications() {
// Request permission
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (!enabled) {
console.log('Notification permission not granted');
return;
}
// Get FCM token
const token = await messaging().getToken();
await api.registerDeviceToken(token);
// Handle token refresh
messaging().onTokenRefresh(async (newToken) => {
await api.registerDeviceToken(newToken);
});
// Foreground message handler
messaging().onMessage(async (remoteMessage) => {
const notification = parseNotification(remoteMessage);
// Show in-app notification
showInAppNotification(notification);
});
// Background message handler
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
// Handle background notification
// This is where you can update local state, cache, etc.
console.log('Background message:', remoteMessage);
});
}
// In-app notification banner
function InAppNotification({ notification, onPress, onDismiss }: {
notification: ZomatoNotification;
onPress: () => void;
onDismiss: () => void;
}) {
return (
<Animated.View style={styles.notificationBanner}>
<Pressable onPress={onPress} style={styles.notificationContent}>
{notification.imageUrl && (
<Image
source={{ uri: notification.imageUrl }}
style={styles.notificationImage}
/>
)}
<View style={styles.notificationText}>
<Text style={styles.notificationTitle}>{notification.title}</Text>
<Text style={styles.notificationBody}>{notification.body}</Text>
</View>
</Pressable>
<Pressable onPress={onDismiss} style={styles.dismissButton}>
<CloseIcon size={20} color="#9C9C9C" />
</Pressable>
</Animated.View>
);
}
10. Ratings & Reviews
10.1 Post-Delivery Rating Flow
function RateOrderScreen({ orderId }: { orderId: string }) {
const { data: order } = useOrder(orderId);
const [ratings, setRatings] = useState({
overall: 0,
food: 0,
delivery: 0,
packaging: 0,
});
const [review, setReview] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [photos, setPhotos] = useState<string[]>([]);
const [tip, setTip] = useState(0);
const handleSubmit = async () => {
await api.submitRating({
orderId,
ratings,
review,
tags: selectedTags,
photos,
tipAmount: tip,
});
navigation.navigate('Home');
};
return (
<ScrollView style={styles.rateScreen}>
{/* Overall rating */}
<View style={styles.ratingSection}>
<Text style={styles.ratingTitle}>How was your order?</Text>
<StarRatingInput
value={ratings.overall}
onChange={(value) => setRatings((r) => ({ ...r, overall: value }))}
size={48}
/>
<Text style={styles.ratingLabel}>
{getRatingLabel(ratings.overall)}
</Text>
</View>
{/* Individual ratings */}
<View style={styles.detailedRatings}>
<RatingRow
label="Food Quality"
value={ratings.food}
onChange={(value) => setRatings((r) => ({ ...r, food: value }))}
/>
<RatingRow
label="Delivery Experience"
value={ratings.delivery}
onChange={(value) => setRatings((r) => ({ ...r, delivery: value }))}
/>
<RatingRow
label="Packaging"
value={ratings.packaging}
onChange={(value) => setRatings((r) => ({ ...r, packaging: value }))}
/>
</View>
{/* Quick tags */}
<View style={styles.tagsSection}>
<Text style={styles.tagsTitle}>What did you like?</Text>
<View style={styles.tagsGrid}>
{REVIEW_TAGS.map((tag) => (
<TagChip
key={tag}
label={tag}
selected={selectedTags.includes(tag)}
onPress={() => toggleTag(tag)}
/>
))}
</View>
</View>
{/* Review text */}
<View style={styles.reviewSection}>
<TextInput
value={review}
onChangeText={setReview}
placeholder="Write a review (optional)"
multiline
style={styles.reviewInput}
/>
</View>
{/* Photo upload */}
<View style={styles.photoSection}>
<Text style={styles.photoTitle}>Add Photos</Text>
<PhotoUploader
photos={photos}
onAdd={(uri) => setPhotos([...photos, uri])}
onRemove={(index) => setPhotos(photos.filter((_, i) => i !== index))}
maxPhotos={5}
/>
</View>
{/* Tip delivery partner */}
<View style={styles.tipSection}>
<Text style={styles.tipTitle}>Tip your delivery partner</Text>
<TipSelector
value={tip}
options={[20, 30, 50, 100]}
onChange={setTip}
/>
</View>
{/* Submit */}
<Button onPress={handleSubmit} style={styles.submitButton}>
Submit Rating
</Button>
</ScrollView>
);
}
const REVIEW_TAGS = [
'Tasty Food',
'Good Quantity',
'Fresh Ingredients',
'Quick Delivery',
'Good Packaging',
'Value for Money',
'Polite Delivery Partner',
'On Time',
];
11. Offers & Promotions
11.1 Coupon System
interface Coupon {
code: string;
title: string;
description: string;
discountType: 'FLAT' | 'PERCENTAGE';
discountValue: number;
maxDiscount?: number; // For percentage coupons
minOrderValue: number;
validUntil: Date;
applicableRestaurants: string[] | 'ALL';
applicableCuisines: string[] | 'ALL';
usageLimit: number;
usedCount: number;
terms: string[];
}
function CouponCard({ coupon, onApply }: { coupon: Coupon; onApply: () => void }) {
const isExpiringSoon = differenceInHours(coupon.validUntil, new Date()) < 24;
return (
<View style={styles.couponCard}>
<View style={styles.couponLeft}>
<View style={styles.couponCircle} />
</View>
<View style={styles.couponContent}>
<Text style={styles.couponCode}>{coupon.code}</Text>
<Text style={styles.couponTitle}>{coupon.title}</Text>
<Text style={styles.couponDescription}>{coupon.description}</Text>
{isExpiringSoon && (
<View style={styles.expiryBadge}>
<ClockIcon size={12} color="#E23744" />
<Text style={styles.expiryText}>
Expires in {formatDistanceToNow(coupon.validUntil)}
</Text>
</View>
)}
</View>
<Pressable onPress={onApply} style={styles.applyButton}>
<Text style={styles.applyText}>APPLY</Text>
</Pressable>
</View>
);
}
function CouponBottomSheet({ onApply }: { onApply: (code: string) => void }) {
const [inputCode, setInputCode] = useState('');
const { data: availableCoupons } = useAvailableCoupons();
const { data: bankOffers } = useBankOffers();
return (
<BottomSheet snapPoints={['70%']}>
<View style={styles.couponSheet}>
{/* Manual input */}
<View style={styles.codeInput}>
<TextInput
value={inputCode}
onChangeText={setInputCode}
placeholder="Enter coupon code"
style={styles.codeTextInput}
autoCapitalize="characters"
/>
<Button
onPress={() => onApply(inputCode)}
disabled={inputCode.length < 3}
>
Apply
</Button>
</View>
{/* Available coupons */}
<Section title="Available Coupons">
{availableCoupons?.map((coupon) => (
<CouponCard
key={coupon.code}
coupon={coupon}
onApply={() => onApply(coupon.code)}
/>
))}
</Section>
{/* Bank offers */}
<Section title="Bank Offers">
{bankOffers?.map((offer) => (
<BankOfferCard key={offer.id} offer={offer} />
))}
</Section>
</View>
</BottomSheet>
);
}
12. Analytics & Observability
12.1 Event Tracking
// Analytics events
const Analytics = {
// Screen views
trackScreenView(screenName: string, params?: Record<string, unknown>) {
firebase.analytics().logScreenView({
screen_name: screenName,
screen_class: screenName,
...params,
});
},
// Search events
trackSearch(query: string, resultCount: number) {
firebase.analytics().logSearch({
search_term: query,
number_of_results: resultCount,
});
},
// Restaurant interactions
trackRestaurantView(restaurant: Restaurant) {
firebase.analytics().logViewItem({
item_id: restaurant.id,
item_name: restaurant.name,
item_category: restaurant.cuisines[0],
});
},
// Cart events
trackAddToCart(item: MenuItem, customizations: Customization[], quantity: number) {
firebase.analytics().logAddToCart({
item_id: item.id,
item_name: item.name,
item_category: item.category,
quantity,
price: item.price,
});
},
// Checkout events
trackBeginCheckout(cart: Cart) {
firebase.analytics().logBeginCheckout({
value: cart.total,
currency: 'INR',
items: cart.items.map((item) => ({
item_id: item.id,
item_name: item.name,
price: item.price,
quantity: item.quantity,
})),
});
},
// Purchase events
trackPurchase(order: Order) {
firebase.analytics().logPurchase({
transaction_id: order.id,
value: order.total,
currency: 'INR',
items: order.items.map((item) => ({
item_id: item.id,
item_name: item.name,
price: item.price,
quantity: item.quantity,
})),
});
},
// Custom events
trackCustomEvent(name: string, params?: Record<string, unknown>) {
firebase.analytics().logEvent(name, params);
},
};
12.2 Error Tracking
import * as Sentry from '@sentry/react-native';
Sentry.init({
dsn: 'https://...@sentry.io/...',
environment: __DEV__ ? 'development' : 'production',
tracesSampleRate: 0.1,
attachStacktrace: true,
beforeSend(event) {
// Remove sensitive data
if (event.request?.data) {
event.request.data = '[Filtered]';
}
return event;
},
});
// Track API errors
function trackAPIError(error: Error, context: Record<string, unknown>) {
Sentry.captureException(error, {
tags: {
type: 'api_error',
endpoint: context.endpoint,
},
extra: {
statusCode: context.statusCode,
requestId: context.requestId,
},
});
}
// Track component errors
class ErrorBoundary extends React.Component<Props, State> {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
Sentry.captureException(error, {
extra: {
componentStack: errorInfo.componentStack,
},
});
}
render() {
if (this.state.hasError) {
return <ErrorFallback onRetry={() => this.setState({ hasError: false })} />;
}
return this.props.children;
}
}
13. Architecture Evolution
13.1 Phase 1: Native Apps (2010-2015)
Architecture:
- Separate iOS (Objective-C) and Android (Java) apps
- Restaurant discovery focus
- No food delivery
Pain points:
- Duplicate feature development
- Inconsistent UX across platforms
- Slow iteration speed
13.2 Phase 2: Food Delivery Launch (2015-2018)
Changes:
- Added food delivery
- Real-time tracking
- Payment integration
Challenges:
- Complex state management
- Real-time sync issues
- Performance on low-end devices
13.3 Phase 3: React Native Migration (2018-2020)
Changes:
- Migrated to React Native
- Shared codebase
- Unified design system
Improvements:
- 80% code sharing
- Faster feature development
- Consistent UX
New challenges:
- Performance optimization
- Native module complexity
13.4 Phase 4: Scale & Optimization (2020-Present)
Changes:
- FlashList for performance
- Zustand for state management
- Optimized image loading
- Peak time handling
Current focus:
- AI-powered recommendations
- Voice ordering
- 10-minute delivery (Zomato Instant)
14. Future Architecture
14.1 10-Minute Delivery (Zomato Instant)
// Instant delivery requires different UI patterns
function InstantDeliveryCard({ restaurant }: { restaurant: Restaurant }) {
const { isInInstantZone } = useUserLocation();
const { prepTime } = restaurant.instantDelivery;
if (!isInInstantZone || !restaurant.instantDelivery.available) {
return null;
}
return (
<View style={styles.instantCard}>
<View style={styles.instantBadge}>
<BoltIcon size={16} color="#FCCA1C" />
<Text style={styles.instantText}>
{prepTime} mins
</Text>
</View>
<Text style={styles.instantLabel}>Instant Delivery</Text>
</View>
);
}
14.2 Voice Ordering
// Voice-based food ordering
class VoiceOrderingController {
private recognition: SpeechRecognition;
async processVoiceOrder(transcript: string) {
const intent = await classifyFoodIntent(transcript);
switch (intent.type) {
case 'ORDER_FOOD':
// "Order biryani from Behrouz"
const restaurant = await findRestaurant(intent.restaurantName);
const item = await findMenuItem(restaurant.id, intent.dishName);
navigation.navigate('Restaurant', { restaurantId: restaurant.id, highlightItem: item.id });
break;
case 'REORDER':
// "Reorder my last order"
const lastOrder = await api.getLastOrder();
await prefillCartFromOrder(lastOrder.id);
navigation.navigate('Cart');
break;
case 'TRACK_ORDER':
// "Where is my order?"
const activeOrder = await api.getActiveOrder();
navigation.navigate('OrderTracking', { orderId: activeOrder.id });
break;
}
}
}
14.3 AI-Powered Recommendations
// Personalized AI recommendations
function useAIRecommendations() {
const userContext = useUserContext();
return useQuery({
queryKey: ['ai-recommendations', userContext],
queryFn: async () => {
const recommendations = await api.getAIRecommendations({
timeOfDay: getCurrentMealTime(),
weather: await getWeather(userContext.location),
recentOrders: userContext.recentOrders,
preferences: userContext.dietaryPreferences,
mood: userContext.lastSearchTerms, // Infer mood from searches
});
return recommendations;
},
});
}
// AI-powered dish suggestions
function AISuggestionCard({ suggestion }: { suggestion: AISuggestion }) {
return (
<View style={styles.aiCard}>
<View style={styles.aiHeader}>
<SparkleIcon size={16} color="#E23744" />
<Text style={styles.aiLabel}>Recommended for you</Text>
</View>
<Text style={styles.aiReason}>{suggestion.reason}</Text>
<FoodImage uri={suggestion.dish.imageUrl} width={100} height={100} />
<Text style={styles.dishName}>{suggestion.dish.name}</Text>
<Text style={styles.restaurantName}>{suggestion.restaurant.name}</Text>
</View>
);
}
Conclusion
Zomato's frontend architecture is a masterclass in real-time food delivery at hyperscale. The system serves 80M+ monthly users while orchestrating:
- Complex menu rendering: Nested customizations with dynamic pricing
- Real-time order tracking: Live map with smooth marker animations
- Cart management: Multi-item, multi-customization persistence
- Payment integration: UPI, cards, wallets, COD with seamless flows
- Search & discovery: Sub-200ms instant search across millions of items
- Peak time handling: Graceful degradation during 3-5x traffic spikes
- Image optimization: Fast loading for appetite-appealing food photos
- Push notifications: Rich notifications for order status updates
Key engineering principles:
- React Native for velocity: 80% code sharing, rapid iteration
- State machine thinking: Complex flows modeled explicitly
- Optimistic UI: Never make users wait unnecessarily
- Graceful degradation: Work on any network condition
- Image-first design: Food photos drive conversion
The future points toward 10-minute delivery, voice ordering, and AI-powered personalization—but the foundational principle remains: get delicious food to hungry users as fast as possible.
Zomato's frontend isn't just displaying menus. It's orchestrating a real-time logistics network while making ordering feel effortless—and that's the result of relentless engineering focus on speed and reliability.
Engineering food delivery is about managing impatience. Zomato's frontend architecture makes waiting feel shorter and ordering feel instant—that's the magic of great UX engineering.
What did you think?