DEV Community

Cover image for System Design Interview Simulation (Uber Eats IOS)
Aleksei Barinov
Aleksei Barinov

Posted on

System Design Interview Simulation (Uber Eats IOS)

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:

  1. Unstable network: The user steps into an elevator, and 5G becomes Edge.
  2. Battery: If your app drains the battery in an hour, it’ll be deleted the next day or that very moment.
  3. 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-Key header 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-Key has 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.

  1. Short Polling (GET /orders/{id}/status) — immediately no. It drains the battery and burdens the backend.
  2. Push Notifications (APNS) — fine for background updates, but unreliable for active screens due to delivery delays.
  3. WebSockets — the ideal solution here. We establish a persistent connection while the user is on the order screen, and the server pushes events like OrderCreatedCooking, and CourierAssigned directly.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }
              ]
            }
          ]
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 new cart_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
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Response Body:

{
  "order_id": "ord_999",
  "status": "created", 
  "estimated_delivery_time": "2025-12-19T19:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 responsivenessreliability, 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 MenuRepository to 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? An actor guarantees 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:
    1. ViewModel calls cartService.addItem(menuItem).
    2. CartService (as an actorimmediately updates its local state (@Published var items) and publishes it. The UI updates instantly—this is Optimistic UI.
    3. In the background, CartService calls cartRepository.syncCart(), sending the POST /cart/sync request to the backend.
    4. If the server returns an error (e.g., "item out of stock"), CartService rolls 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).
    1. 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.
    2. Network Path: In parallel, it fires off the GET /menu request.
    3. Sync: If the network responds successfully, the repository updates the local cache and sends updated data to subscribers (via AsyncStream or Combine 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 sends POST /cart/sync.
    • Key nuance: The repository must handle network errors. If the request fails (timeout), it returns the error to CartService so 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.

3. OrderRepository: Transactional Reliability

The most critical component. Errors here cost real money.

  • Responsibility: Handle the payment process.
  • The Flow:
    1. Tokenization: Interacts with PaymentService (wrapper over Apple Pay / Stripe) to get a cryptographic payment_token. The repository doesn't know Apple Pay UI details—it just asks "give me a token."
    2. Idempotency: Generates a unique UUID for the idempotency key. This guarantees that even with three retries, we create only one order.
    3. Submission: Sends POST /orders/submit.
    4. 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.

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 classic UITableView, any counter update ("+1") required either precise reloadRows(at:)(hard index calculations) or full reloadData() (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:

  1. O(N) Diffing: When CartService sends an updated cart, we create a new "Snapshot." The system computes the diff in a background thread and applies animations.
  2. 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.

  1. Send request to server.
  2. Wait for response.
  3. 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 UIImageView kills 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Apple Pay --=> Fallback: Stripe Elements (embedded form)
  2. Stripe --=> Fallback: Saved cards
  3. 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)