Part 1. Introduction: Why Mobile System Design Isn’t About Databases
Let’s be honest: when a mobile developer hears “System Design Interview,” their palms start to sweat. We’re used to thinking this is backend territory. You immediately picture questions about PostgreSQL sharding, Consistent Hashing, or whether to choose Kafka over RabbitMQ.
But if you're interviewing for a senior/lead iOS developer position at a major tech company, no one expects you to be able to design data centers, but you should be able to handle the mobile side of things.
Mobile System Design is about something else that goes for backend engineers. It’s about the harsh reality of mobile devices. Your main enemies are not RPS (requests per second), but:
- Unstable network: The user steps into an elevator, and 5G becomes Edge.
- Battery: If your app drains the battery in an hour, it’ll be deleted the next day or that very moment.
- Memory and UI: How do you scroll through a 500-item menu with images at 60 FPS without crashing from an OOM (Out of Memory) error?
Our Task
Today we’re designing the core of a food delivery app. Imagine you’re in an interview, and you’re asked:
“Design the client side of a food ordering app. Ordering from Restaurant feature”
Important note: Always narrow the scope during interviews. Don’t try to design the entire app at once. We won’tdiscuss:
- Login screens or authentication
- Map rendering or courier tracking in MapKit/Google SDK or MapLibre (that’s a topic for another article)
We’ll focus on the “Money Flow”, the user journey that drives business value:
Restaurant menu → Add to cart → Order validation → Order status tracking.
Before jumping into flowcharts and backend endpoint discussions, let’s define the Functional and Non-functional requirements. They’re the foundation of any solid design.
By the way, if you’re currently preparing for mobile interviews and want to practice not only system design but also classic Swift, UIKit/SwiftUI, and architecture questions — check out my app “Prepare for mobile interview” on the App Store.
Just search for “Prepare for mobile interview” or use this direct link: https://apps.apple.com/cy/app/prepare-for-mobile-interview/id6756423817
Functional Requirements (What we build)
These are the core features users must be able to perform.
- View menu: Users can see the full list of dishes and drinks, grouped by categories.
- Customize dish: Users can choose item modifiers (e.g. pizza size, steak doneness, extra sauces).
- Manage cart: Users can add/remove dishes and see the total cost including all modifiers.
- Place order: Users can confirm and pay for their order.
- Track order status: After payment, users can see the current status (e.g. “Accepted,” “Cooking,” “On the way”).
Non-Functional Requirements (How we build it)
These are the system’s quality attributes. In some ways, they matter even more than functionality — they determine whether users love or hate your app.
-
Responsiveness:
- The interface must be smooth (60+ FPS). No freezes while scrolling through the menu or tapping “Add to Cart.”
- The UI should react instantly to user actions, even if the network request hasn’t completed yet (this is called Optimistic UI, and we’ll discuss it later).
-
Reliability and offline support:
- The restaurant menu should still be viewable with a poor connection (after initial load).
- The app must not send duplicate orders if the network glitches during payment.
-
Data consistency:
- The cart state must sync across a user’s devices (start on iPhone, continue on iPad).
- Prices in the cart must always stay up to date.
-
Low update latency:
- The order status should update in real time — no “pull to refresh” required.
Once we’ve defined these requirements, we can move forward knowing which technical solutions we’ll need to implement them.
Part 2. API Contract: Aligning with the Backend
Every good design starts by defining boundaries. Before writing a single line of Swift code, we must define our contract with the backend. This contract naturally follows from our requirements.
We treat the backend as a black box—but we tell it exactly what data we need and in what format, based on our functional (F) and non-functional (NF) requirements.
Throughout this section, I’ll mark (F) for functional and (NF) for non-functional requirements.
Main REST Endpoints
1. Fetching the Menu
GET /restaurants/{id}/menu
- Purpose: This supports (F) “View Menu” and “Customize Dish.”
-
Response structure: Here lies our first trap. To ensure (NF) “Responsiveness,” we can’t afford N+1 requests (first for categories, then for dishes). We ask the backend to return the entire menu as one well-structured JSON model containing categories (
"burgers","drinks"), menu items, and nested modifier groups ("size","toppings"). - Offline mode: Once loaded, this structure can be cached to disk. That directly implements (NF) “Offline Availability,” allowing the user to browse the menu even on an airplane.
2. Cart Synchronization
POST /cart/sync
- Purpose: Implements (F) “Cart Management” and, more importantly, guarantees (NF) “Data Consistency.”
-
Logic: When the user adds an item, we don’t just update a
UILabel. We send the server an array of[ItemID: Quantity, Modifiers]. The server responds with a validated version of the cart, including the final total. This makes the server the Single Source of Truth, ensuring seamless transitions between devices.
3. Checkout
POST /orders/submit
- Purpose: Supports (F) “Order Placement.”
-
Key detail: To ensure (NF) “Reliability,” this request must include an
Idempotency-Keyheader with a unique UUID. -
Scenario: The user taps “Pay,” the app sends the request, but the response is lost due to a network drop. The app retries.
- Without the key: The user is charged twice.
-
With the key: The backend sees that this
Idempotency-Keyhas already been processed and simply returns the previous successful result — no duplicate payment.
Real-Time Updates: How Do We Know the Food Is Ready?
To fulfill (F) “Order Tracking” and (NF) “Low Update Latency,” we need a mechanism to deliver server events to the client in real time.
-
Short Polling (
GET /orders/{id}/status) — immediately no. It drains the battery and burdens the backend. - Push Notifications (APNS) — fine for background updates, but unreliable for active screens due to delivery delays.
-
WebSockets — the ideal solution here. We establish a persistent connection while the user is on the order screen, and the server pushes events like
OrderCreated,Cooking, andCourierAssigneddirectly.
Implementation in Swift 6
In modern Swift (as of late 2025), working with WebSockets has become much more elegant thanks to AsyncSequence. We can wrap “dirty” URLSessionWebSocketTask handling in a clean and structured AsyncStream.
Here’s what it looks like conceptually (pseudocode):
// OrderStatusService.swift
func observeStatus(orderId: String) -> AsyncStream<OrderStatus> {
AsyncStream { continuation in
let task = urlSession.webSocketTask(with: request)
task.resume()
// Launch listener in a separate Task
Task {
while !Task.isCancelled {
do {
// Modern concurrency approach
let message = try await task.receive()
let status = parse(message)
continuation.yield(status)
} catch {
continuation.finish(throwing: error)
break
}
}
}
continuation.onTermination = { _ in
task.cancel(with: .normalClosure, reason: nil)
}
}
}
// Usage in ViewModel
func trackOrder() async {
for await status in service.observeStatus(orderId: "123") {
self.currentStatus = status // Update UI smoothly
}
}
This approach is both efficient and simple to code. However, I wouldn’t recommend writing this pseudocode directly in an interview; your goal there is to design the system, not draft an implementation, still be ready to write some pseudocode if interviewer will ask for that.
Part 3. Model Layer: Designing Data Contracts (DTOs)
We've defined our API endpoints. Now we need to populate them with data. This stage is critical in System Design interviews—it shows whether you can design API Contracts that scale.
We're not just throwing fields together. We're crafting JSON contracts that solve the security, idempotency, and data consistency issues from our requirements.
1. Fetch Menu (GET /restaurants/{id}/menu)
Our goal: Get data structured for complex UI rendering in one pass, without chained follow-up requests.
-
Header:
Authorization: Bearer <token>(Required for personalized pricing or "favorites").
Response Body:
The structure must be hierarchical. Note the nesting: Restaurant -> Categories -> Items -> Modifier Groups -> Options.
{
"restaurant_id": "r_12345",
"currency": "USD",
"categories": [
{
"id": "cat_burgers",
"name": "Burgers",
"items": [
{
"id": "item_bigmac",
"name": "Big Mac",
"description": "Two all-beef patties...",
"price": 5.99,
"is_available": true, // UX: Disable "Add" button if false
"modifier_groups": [
{
"id": "mod_size",
"name": "Size",
"min_selection": 1, // Validation: Required choice
"max_selection": 3,
"options": [
{ "id": "opt_m", "name": "Medium", "price_delta": 0.00 },
{ "id": "opt_l", "name": "Large", "price_delta": 1.50 }
]
}
]
}
]
}
]
}
2. Cart Synchronization (POST /cart/sync)
This is the most architecturally complex request. Many juniors try passing user_id in the request body. That's a security anti-pattern.
We use the Authorization header so the backend identifies the user itself.
The key element here is cart_id.
-
Scenario: User adds first item. We send
cart_id: null. Server creates a session and returns a newcart_id. -
Scenario: User adds second item. We send the received
cart_id. This links items into a single order.
Request Body:
{
"cart_id": "cart_abc_123", // Null on first request
"restaurant_id": "r_12345",
"items": [
{
"menu_item_id": "item_bigmac",
"quantity": 2,
"selected_modifiers": ["opt_l", "opt_cheese"] // Only IDs of selected options
}
]
}
Response Body:
The server acts as Single Source of Truth. It recalculates prices and checks availability.
{
"cart_id": "cart_abc_123", // Save this ID on client!
"total_price": 14.98, // Final price from server
"validation_errors": [], // Array of errors (e.g., "Cheese is out of stock")
"items": [ ... ] // Current item list for cart rendering
}
3. Order Submission (POST /orders/submit)
We use the cart_id received earlier. No need to resend the item list—the server already knows the cart contents.
Payment magic happens here. We never send raw card data.
The client first gets a token from the provider (Apple Pay/Stripe), then sends only that token to our backend.
-
Header:
Authorization: Bearer <token> -
Header:
Idempotency-Key: <UUID>(Generate UUID on client. If network drops and we retry with same key, server won't double-charge).
Request Body:
{
"cart_id": "cart_abc_123",
"payment_method_id": "card_visa_4242",
"delivery_address": {
"lat": 37.77,
"lon": -122.41,
"address_line": "1 Market St"
},
"comment": "Leave at door"
}
Response Body:
{
"order_id": "ord_999",
"status": "created",
"estimated_delivery_time": "2025-12-19T19:30:00Z"
}
4. Order Status (WebSocket Message)
For real-time updates, we subscribe to events via WebSocket. The structure must be flexible (Event-Driven) to support new statuses without app updates.
Payload:
{
"event": "order_updated",
"data": {
"order_id": "ord_999",
"status": "cooking", // Enum: created, cooking, delivering, delivered
"title": "Cooking",
"description": "Chef is making your burger",
"eta_timestamp": "2025-12-19T19:35:00Z"
}
}
Perfect. We've laid a rock-solid foundation of requirements and data contracts. Now we can build the house. At this interview stage, the interviewer wants to see how you think about components and data flows—not specific class names.
Part 4. High-Level Architecture: Putting It All Together
We've designed the data. Now let's design the system that works with it. Our goal: fulfill the non-functional requirements of responsiveness, reliability, and data consistency.
Standard MVVM/VIPER is just the tip of the iceberg. The real challenge is state management and data flows.
Component Diagram (The "Whiteboard" Sketch)
On a whiteboard, we'd draw this:
[ View (UI Layer) ] <-- binds to --> [ ViewModel ] <-- uses --> [ Service Layer (Use Cases) ] <-- uses --> [ Repository Layer ] <-- talks to --> [ Network / Cache ]
The key element that doesn't fit this linear flow is the global cart state.
Service Layer: The App's Brain
This is where business logic lives. We identify two main services.
1. MenuService
-
Responsibility: Menu loading and caching. It uses
MenuRepositoryto fetch data and persists it to a local database (Realm or Core Data) for offline access. This directly fulfills (NF) "Offline Availability."
2. CartService
- Responsibility: Cart state management. This is our Single Source of Truth for everything cart-related.
-
Implementation: In Swift 6, this is a perfect candidate for an
actor. Why? Anactorguarantees that all cart operations (add, remove, change quantity) execute sequentially—even if the user frantically taps buttons across different UI screens. This protects us from Data Races, the most common cause of hiden bugs. -
Data Flow:
-
ViewModelcallscartService.addItem(menuItem). -
CartService(as anactor) immediately updates its local state (@Published var items) and publishes it. The UI updates instantly—this is Optimistic UI. - In the background,
CartServicecallscartRepository.syncCart(), sending thePOST /cart/syncrequest to the backend. - If the server returns an error (e.g., "item out of stock"),
CartServicerolls back the change and publishes the new state. The UI shows an alert.
-
Repository Layer
The repository layer isn't just "where we make requests." It's the data arbitrator. Its job is to decide where to get data from (network, disk, memory) so Service and ViewModel never have to think about it.
1. MenuRepository: "Cache First" Strategy
Restaurant menus are relatively static data. Showing the menu instantly (even slightly stale) is more important than making users stare at a spinner.
- Responsibility: Ensure menu availability offline.
-
Strategy: We use the "Stale-While-Revalidate" pattern (hybrid approach).
- Fast Path: On menu request, the repository immediately returns data from local storage (Core Data / Realm / JSON file on disk) if available. This ensures instant screen launch.
-
Network Path: In parallel, it fires off the
GET /menurequest. -
Sync: If the network responds successfully, the repository updates the local cache and sends updated data to subscribers (via
AsyncStreamorCombine Publisher).
- Why this matters: If the user enters the subway and loses connection, they can still browse food. This directly fulfills (NF) "Reliability."
2. CartRepository: Truth Synchronizer
Different strategy here. Caching the cart is dangerous since prices and availability change dynamically.
- Responsibility: Synchronize cart state with the server.
-
Strategy: "Network First" with race condition protection.
- The repository stores the current
cart_id. - On
sync(items: [CartItem]), it forms the JSON and sendsPOST /cart/sync. -
Key nuance: The repository must handle network errors. If the request fails (timeout), it returns the error to
CartServiceso it can rollback the optimistic UI update. We can't "quietly" cache adding an item if we don't know if it's in stock.
- The repository stores the current
3. OrderRepository: Transactional Reliability
The most critical component. Errors here cost real money.
- Responsibility: Handle the payment process.
-
The Flow:
-
Tokenization: Interacts with
PaymentService(wrapper over Apple Pay / Stripe) to get a cryptographicpayment_token. The repository doesn't know Apple Pay UI details—it just asks "give me a token." -
Idempotency: Generates a unique
UUIDfor the idempotency key. This guarantees that even with three retries, we create only one order. -
Submission: Sends
POST /orders/submit. - Error Handling: If the network drops but we're unsure if the order went through, the repository should (optionally) poll order status or return a specific "Check Status" error—so the UI doesn't tell the user "Try again" when we're not certain.
-
Tokenization: Interacts with
4. OrderStatusRepository: WebSocket Wrapper
- Responsibility: Manage persistent connections.
- Life Cycle: The repository knows when to open the socket (on successful order) and when to close it (order delivered).
- Reconnection Logic: If the socket drops (Ping/Pong timeout), the repository handles Exponential Backoff (retry after 1s, then 2s, 4s) to restore connection without overwhelming the server.
Part 5. UI Layer: Performance and User Experience
We've designed the backend and reliable data layer. But users don't see JSON—they see burger images and +/- buttons. If this interface lags during scrolling, our entire architecture fails.
1. Menu Rendering
The restaurant menu is a complex screen for our feature.
- What we have: Hundreds of elements, different cell types (headers, promo banners, dish cards), sticky category headers, and tons of images.
-
The
reloadData()Problem: In classicUITableView, any counter update ("+1") required either precisereloadRows(at:)(hard index calculations) or fullreloadData()(flickering and scroll position loss).
Solution: UICollectionView + Diffable Data Source
We use UICollectionViewCompositionalLayout for layout and UICollectionViewDiffableDataSource for data.
Why this is a killer feature:
-
O(N) Diffing: When
CartServicesends an updated cart, we create a new "Snapshot." The system computes the diff in a background thread and applies animations. -
No More
IndexOutOfRange: The most common UIKit crash (delete array item but don't update table) is mathematically impossible. The snapshot is the source of truth.
Pro-Tip for interviews:
It'll impress if you mention Section Snapshots (available since iOS 15).
Instead of recalculating the entire menu when one category changes, we update only the "Burgers" section. As you understand, this is critical for performance with 500+ menu items.
2. Counter "Flickering" Problem
Imagine: user taps "+" on pizza.
- Send request to server.
- Wait for response.
- Update cell.
Result: 0.5s delay. Feels like "lag."
Solution: Optimistic UI + Payload Update
We don't wait for the server. We update UI instantly. But how to update only the number without redrawing the entire dish image?
DiffableDataSource has reconfigureItems.
Instead of heavy reloadItems (destroys/recreates cell), we call reconfigureItems. This keeps the cell alive but asks it to refresh content. The image doesn't flicker—only the number label changes.
3. Images
Hundreds of images in the menu. Loading them all at once creates a "checkerboard" during fast scrolling.
Caching Strategy:
In this interview section, the interviewer wants to know what you'll do with these images. Here you can mention:
- Memory Cache: Store images of visible cells in RAM (LRU Cache).
-
Prefetching (Smart Preloading): Implement
UICollectionViewDataSourcePrefetching. This gives us indices of cells the user is approaching during scroll. We start network requests for images. Even if the image doesn't fully download, we save precious milliseconds on DNS Lookup and TCP Handshake ("warm up" the connection), so content appears almost instantly. -
Downsampling: Critically important! The server might send 4000x3000 photos. Loading that into a 100x100
UIImageViewkills memory. Good practice is converting received images into convenient small thumbnails.
Perfect. Now we move to the final part. Here we'll demonstrate engineering maturity—thinking about what can go wrong and planning for it upfront.
Part 6. Edge Cases and Trade-offs
We've reached the end of our System Design interview. The architecture looks solid. But an experienced interviewer will ask the most important questions:
"What if the user loses network during payment?"
"Why WebSocket over Push Notifications?"
"Where's the trade-off between speed and data accuracy?"
This tests engineering thinking, not API knowledge.
1. Trade-off: Optimistic UI vs Data Consistency
What we chose: Show item in cart instantly, without waiting for server.
Pros:
- Responsive UI (0.01s vs 0.5s).
- User feels in control.
Cons:
- Server might respond "Item out of stock." Must rollback UI (show alert "Sorry, unavailable").
Alternative (Pessimistic UI): Disable button until server responds.
Why not chosen: 80% requests succeed. Better handle 20% errors than make 100% users wait.
2. Edge Case: "Payment Problem"
Scenario: Apple Pay succeeds. Sent POST /orders/submit. Network drops.
Our strategy: State Machine with Fallback.
PendingPayment ──[Network OK]--=> OrderCreated
│
|───[Network Fail]--=> CheckStatus (GET /orders/{id})
│
|─[Order exists]--=> WebSocket
|─[No order]--=> RetryWithSameIdempotencyKey
Trade-off: Extra GET /orders/status request. But avoids double payment.
3. WebSocket vs Push Notifications: Battery vs Real-time
WebSocket (our choice):
- Real-time (0.1s latency)
- Works on active screen
- Drains battery (persistent connection)
- Doesn't work in background
Push Notifications (alternative):
- Battery efficient
- Works in background
- 1-30s delay (APNS queue)
- No delivery guarantee
Solution: Hybrid. WebSocket while user on screen. Push for background.
4. Menu Caching: Stale Data vs UX
Cache First (our choice):
- Menu shows instantly (even 2-hour cache)
- Works offline
- Prices might have changed
Network First (alternative):
- Always fresh data
- 1-2s spinner on every launch
Compromise: TTL = 1 hour + ETag. Show cache, update in parallel.
5. "What if Apple Pay fails?"
Fallback Strategy:
- Apple Pay --=> Fallback: Stripe Elements (embedded form)
- Stripe --=> Fallback: Saved cards
- Cards --=> Fallback: Cash on Delivery (if available)
Each step boosts conversion by 5-10%.
Conclusion
We've built a system that's not perfect, but works in the real world. Every choice is a deliberate trade-off between speed, reliability, battery, and complexity.
Real System Design isn't about "perfect architecture"—it's about justifying your compromises. That's why we started with requirements: they provide criteria for evaluating every decision.
If I missed something or you'd approach this differently, let's discuss in the comments. Share your experience. Maybe you have your own step-by-step plan for System Design interviews. What do you emphasize? What do you skip? I'd love to read your thoughts, as would others in our community.
Thanks for reading — this was Aleksei Barinov. See you in the next deep dive.
P.S.
If you're actively preparing for iOS and mobile interviews, check out my app "Prepare for mobile interview" — a focused companion with questions on Swift, UIKit, SwiftUI, concurrency, architecture, and mobile system design.
Find it in the App Store by searching "Prepare for mobile interview" or directly via this link:
https://apps.apple.com/cy/app/prepare-for-mobile-interview/id6756423817



Top comments (0)