Spend the first ten minutes pinning down requirements, then sketch the client architecture and the API, then go deep on the one or two parts that are hard on a phone. The grade is for reasoning about trade-offs under iOS constraints, not for the number of boxes.
A timeline that works
Requirements (5 to 10 min): what the user does, which screens are in scope, and the non-functional side: offline, real-time, data volume, minimum iOS version. Say out loud what you leave out.
Numbers (2 to 3 min): items per page, payload and image sizes, how often updates arrive. They decide caching, transport and memory.
High level (10 to 15 min): screens, view models, a repository over the network client and a local store, and the API contract: endpoints, pagination, payload shape. On a mobile round the API counts for more than the server's internals.
Deep dive (15 to 20 min): the risky part (sync, media, real-time, background delivery); offer a choice if the interviewer does not pick.
Wrap-up (5 min): failure modes, metrics, a rollout behind a flag.
What earns the grade
Questions before boxes, and every choice tied to a requirement: "cursor pagination, because the feed changes while you scroll".
iOS reality: the app is suspended seconds after the user leaves, a suspended or killed app wakes only for a push or when the system chooses (a BGTaskScheduler task, a finished background transfer), jetsam caps memory, and old versions stay installed for months.
Leading the conversation, which is most of what "senior" means here. Opening with a class diagram or never saying what happens when a request fails sinks it.
iOSSenior
iOS System Design
The design round: feed, chat, live tracking, uploads, pagination, caching, idempotency, server-driven UI, analytics, super-apps
Questions closed
0 / 16
- 0Review
Questions
Default to REST with JSON: URLSession, Codable, HTTP caching and every debugging proxy work out of the box. GraphQL pays off when many screens need different slices of many services, gRPC when the company already lives on protobuf or needs streaming; both move work into the client.
REST: one URL per resource, so URLCache, ETag and a CDN cache GET requests for free, and the traffic is readable in a proxy. The cost is round trips: a screen built from five resources makes five requests, unless the backend adds an endpoint shaped like the screen.
GraphQL: one request returns exactly the fields the screen needs, and Apollo iOS generates Swift types from the queries. The price: requests usually go as a POST to one URL, so HTTP caching gives way to a normalised client cache keyed by id; errors arrive in an errors array with status 200; the server must guard against expensive queries. Persisted queries (a hash instead of the query text) shrink requests, let the server accept only known ones and can go as cacheable GETs.
gRPC: protobuf contracts generate Swift code (grpc-swift, SwiftProtobuf), binary payloads are small and fast to parse, and streaming is built in. The price: HTTP/2 end to end, unreadable traffic in a proxy, no CDN caching and a bigger binary.
Evolution matters more than style: a build you ship today talks to the API for years. Protobuf field numbers and an additive GraphQL schema handle that well; with JSON you keep decoding tolerant and every change additive.
Mixing is normal: REST or GraphQL for screens, a WebSocket or a gRPC stream for live data.
An offset counts rows from the top, so every post added or deleted above the user's position shifts the window: an insert repeats the last item of the previous page, a delete skips one. A cursor names the last item the client has and asks for what comes after it, which nothing above can move.
What a cursor is: the sort key of the last row (created_at plus id as a tie-breaker), encoded by the server as an opaque string. The app stores it and sends it back as after=; it never parses or builds one, so the server can change the encoding.
Why the tie-breaker: many rows share a timestamp, and without id in the key a page boundary can fall between them and lose rows.
New posts at the top: a separate request, "newer than my first item". Show a "New posts" pill instead of inserting above the user, so the list never jumps under their finger.
Ranked feeds: when a recommendation model reorders on every request, there is no stable key. The server snapshots the ranking per session and the cursor points into that snapshot; when it expires, the feed starts over.
Still dedupe on the client: a retried or re-ranked page can repeat an item, and a diffable data source crashes on duplicate ids, so drop ids you already have.
Bonus: a keyset query walks the index and costs the same on page 500 as on page 1, while OFFSET 10000 reads ten thousand rows and throws them away.
Ask what a stale value costs for each kind of data, not whether it may be stale. Most of the forty are config, reference data and content that can be drawn from disk at once and refreshed behind it; only a few values, like a balance or a price at checkout, must be fetched before you trust them.
Sort the data
Rarely changes (feature flags, cities, categories): cache for hours or days, revalidate with an ETag, bundle a default for the first launch, and serve it from one config endpoint instead of eight.
Content (feed, catalogue, profile): stale-while-revalidate. Render the last copy from your store, refresh in the background, update in place. Fresh means fresh within seconds of opening, not before the first frame.
Must be exact (balance, stock at checkout): fetch every time, but show the last known value with "updated 5 min ago" instead of a spinner. The server checks the price at checkout anyway, so a list may show a cached one.
Cut the cold start
Only the first screen's requests run before the first frame; the rest wait until their screen opens, or run after launch at a lower priority.
Coalesce duplicates: three components asking for /me at launch share one in-flight request.
Invalidate on events rather than short lifetimes: after an edit, update the cache from the response; a version number in the config (catalogVersion: 42) or a silent push tells the app what changed.
Then measure requests and bytes per cold start and time to first content: those numbers show product the screens are fresh enough.
Let every widget load, fail and retry on its own, starting from its last cached value, so the screen is never hostage to its slowest dependency. On the backend, give the screen one aggregating endpoint with a deadline per section that returns partial results instead of waiting for everything.
Client
Draw the layout at once, each card with its last known data from disk and its own state: loading, loaded, or failed with Retry. A failed card does not blank its neighbours.
Load the sections independently, balances first, offers last. The bug is usually one await on all six results.
Reserve each card's height, so a late answer does not push the balance the user is reading down the screen, and give each section its own timeout.
Backend
A backend-for-frontend endpoint fans out to the six services inside the data centre, where a call costs milliseconds instead of a cellular round trip of a few hundred, and returns one payload in the screen's shape.
It sets a deadline per section and returns what it has, with an error for each missing part; streaming parts as they finish (chunked JSON, GraphQL @defer) is the next step.
Slow, non-critical sections such as offers are cached on the server.
Cached balances are sensitive: store them with FileProtectionType.complete, delete them on logout, and cover the screen when the scene resigns active, so the app switcher snapshot does not show them.
Two relative toggleLike requests race, and the rollback flips the current state instead of restoring the server's: when the first request times out after the server applied it, its catch re-fills the heart that the second tap had just emptied.
Why it happens: a timeout does not mean the server did nothing. A toggle is not idempotent, so the server's final state depends on which requests arrived, and the catch assumes the failed request was the last thing the user did.
Send absolute state: PUT /posts/42/like and DELETE can be repeated and reordered safely; the last one sent decides.
One request per post: keep the desired state, the server-confirmed state and an unsent flag. Send when a tap is unsent and nothing is in flight, and when a request finishes, send again if the user tapped meanwhile. Ten quick taps during one request end in at most one follow-up.
Send the follow-up even if it matches the confirmed state: a timed-out request may have changed the server, so confirmed is only the last answer you saw.
Roll back to the confirmed state, and only if the user has not tapped since; a quiet toast, not an alert. The count comes from the server's response, not from +1 arithmetic.
A lone timeout stays uncertain: retry the idempotent PUT before rolling back, and let the next refresh of the post settle the rest.
One store keyed by post id, so the feed, the detail screen and a second cell with the same post show the same heart.
With an idempotency key that names the payment, not the request: the app creates it once per order, saves it before sending and reuses it on every retry, and the server stores the first result under that key and returns it for any repeat instead of charging again.
On the server
Insert the key into a table with a unique constraint, scoped to the user, before calling the payment provider; a repeat with the same key reads the stored status and response.
A repeat while the first is still processing gets 409, and the app asks again shortly. The same key with a different amount is a client bug and gets 422.
Pass a key on to the provider (Stripe and most acquirers accept one), because the server's own retry to the provider has the same problem. Keys expire after about a day.
A second line of defence: the order can be paid once (UPDATE orders SET status = 'paid' WHERE id = ? AND status = 'pending').
In the app
A timeout means the outcome is unknown, not failed. Show "Checking your payment" and ask the server for the status of that key instead of offering "Pay again".
A disabled button is good UX but no guarantee: a relaunch, a second device or a retry layer all get past it.
Persist the intent: if the app is killed mid-payment, the next launch finds the unfinished key and resumes with it.
A new key only for a new decision: a changed cart, or another card after a decline.
No clock on the device: the user can change the date in Settings, and "midnight" differs by time zone. The server owns the end as an absolute instant and refuses a purchase after it; the app only displays it, using the server's time plus a monotonic clock that ignores Settings.
Why users saw it: the app compared endsAt with Date() on a phone set back a day (automatic time switched off), or a cached response outlived the sale because its lifetime was longer than the time left.
Send instants, not dates: "endsAt": "2026-10-24T21:00:00Z", decided on the server. "Until midnight" as a date string turns into midnight in whatever zone the phone is in. If product means the user's local midnight, the app sends TimeZone.current.identifier and the server still computes the instant.
A clock the user cannot move: take serverTime from the response and anchor it to ContinuousClock (iOS 16+), which is monotonic, keeps counting while the phone sleeps and ignores the date in Settings. It resets on reboot, so never persist an Instant: re-sync from the next response and on every return to the foreground.
Cache no longer than the sale: a cached offer list expires at the earliest endsAt it contains, and ended offers are filtered with the server clock even when the list comes from the cache.
The server is the guard: the checkout endpoint checks the time itself. Everything on the device is presentation.
The feed is a local cache the UI observes: a repository pages from the API by cursor into a store, the list renders from the store, media loads ahead of the scroll at the size of the cell, and the position is saved as an anchor post id so a relaunch can open where the user left off.
Data flow
API, repository, store (SQLite through GRDB, or SwiftData), view model, then a UICollectionView with a diffable data source or a LazyVStack. The screen never renders a network response directly, so a cold start shows the last feed at once, offline included.
Pages arrive by cursor; pull to refresh asks for posts newer than the top one and shows a "New posts" pill instead of inserting above the user.
Likes and comment counts live in one store keyed by post id, so every screen shows the same numbers.
Prefetching and media
Request the next page several screens before the end, from UICollectionViewDataSourcePrefetching or a visibility threshold, and cancel prefetches for rows flung past.
The payload carries each image's aspect ratio and a tiny placeholder (a dominant colour or a BlurHash), so a cell has its final height before any pixel arrives and the list does not jump.
Ask for the rendition that fits the cell (the API offers several widths) and decode it downsampled; the image pipeline is a design of its own (see the image caching question).
Video autoplays only in the most visible cell, muted, from a small pool of AVPlayers; the other cells show a poster frame.
Back to the same post
Save the id of the top visible post and the offset inside its cell, not contentOffset, which breaks as soon as the content above changes. Save on .background, along with the cursor and the cached pages.
On launch, render from the store and scroll to the anchor: scrollToItem(at:at:animated:) plus the saved offset, or scrollPosition(id:) in SwiftUI.
Agree the product rule: after a few hours most feeds open at the top with fresh content, because a restored stale position looks like a refresh that failed.
Measure scroll hitches, time to the first post on cold start, and how often the user reaches the end of loaded content and sees a spinner.
Preload one step ahead in each direction the user can go: the next segment of the current story and the first segment of the next person's. Keep two or three players for the current and next segment, reused rather than created per tap, and budget memory in decoded full-screen frames.
What to preload
The two gestures are a tap (next segment) and a swipe (next person), so preload exactly those two targets. Anything further is a guess paid for with the user's data.
Images: download and decode ahead at screen size. Video: load the asset's properties (try await asset.load(.duration, .isPlayable)) and let an AVPlayerItem buffer the first seconds; preferredForwardBufferDuration keeps that buffer small.
Prefer HLS so the player adapts the bitrate to the network; lower preferredPeakBitRate on cellular, and preload less in Low Data Mode (NWPath.isConstrained) or Low Power Mode.
Players
Two or three AVPlayers with their layers, reused through replaceCurrentItem(with:). A player per segment pays decoder setup on every tap, and the number of video decoders the hardware runs at once is limited and undocumented, so piling up players ends in playback failures.
Pause and release the item when a page leaves the screen, when the app goes to the background and on an audio interruption.
Memory budget
A decoded frame on a 1290 × 2796 screen is about 14 MB (width × height × 4 bytes). Keep the current, next and previous images decoded, roughly 45 MB, in an NSCache with a totalCostLimit; everything else stays compressed on disk.
On didReceiveMemoryWarningNotification drop the preloads first; jetsam can kill the app without a second warning.
The rest of the viewer
The progress bar follows the player's time (addPeriodicTimeObserver), not a timer, so a stall pauses it. A long press pauses, and tap zones go back or forward.
"Seen" marks are queued and sent in batches, and the tray reorders only after the viewer closes, so nothing moves under the user.
Save the draft and processed photos locally first, upload each photo as a file task in a background URLSession, and create the listing with an idempotent request once every photo has a server id.
Preparing photos
PhotosPicker or PHPickerViewController needs no photo library permission and returns only the picked items.
Downscale with ImageIO to what the marketplace shows (say 2048 px on the long side), one photo at a time, off the main actor: ten full-size decodes at once can get the app killed for memory.
Encode JPEG if the backend cannot read HEIC; a fresh encode drops EXIF, GPS location included, which a public listing wants. kCGImageSourceCreateThumbnailWithTransform bakes in the orientation.
The draft
A row with the listing fields, the photo order and, per photo, the local file, a state (pending, uploading, uploaded(serverID), failed) and an attempt count, saved on every edit.
Processed files go to Application Support: the system may purge tmp and Caches while the draft waits.
The upload queue
One upload task per photo, from a file to a pre-signed storage URL, all ten given to one background session at once: they run while the app is suspended and wake it when done (a force-quit cancels them). Feeding them one by one from background wake-ups hits a rate limiter whose delay grows with each relaunch.
The session waits out lost connectivity itself and, since iOS 17, resumes an interrupted upload if the server supports it. A photo the server rejects goes to failed and is retried with backoff.
After a relaunch, recreate the session with the same identifier and reconcile getAllTasks with the draft: a photo marked uploading without a task goes back in the queue.
Publishing
POST /listings with the photo ids in the draft's order and a client-generated listing id, so a retried publish cannot create two listings. If the last photo lands during a background wake-up, publish right there under beginBackgroundTask.
The server deletes uploaded photos that no listing claims within a day.
Show "Publishing, 6 of 10" and let the user leave; a local notification reports a failure that needs them.
The local database is the chat: the UI renders from it, an outgoing message is written there first with a client id, and a sync layer uses a socket while the app is open and catches up by sequence number after every gap. In the background APNs and a notification service extension take over.
Transport
Foreground: one persistent connection (a WebSocket, or a custom protocol over TCP like Telegram's MTProto) for incoming messages, typing and read receipts. Sending can share it or use plain HTTPS.
Background: iOS suspends the app seconds after the user leaves and the socket dies with it. New messages arrive as alert pushes; reconnect on every return to the foreground.
Calls are the exception: PushKit VoIP pushes, each one reported to CallKit.
Message states
sending (saved locally, shown at once), sent (the server acknowledged it and assigned an id and a sequence number), delivered, read, and failed with Retry.
The client generates a UUID per message and sends it with every attempt, so the server drops duplicates and the acknowledgement finds the local row.
Storage and ordering
SQLite through GRDB, or Core Data: messages indexed by chat and server sequence, plus an outbox row written in the same transaction as the message.
Order by the server's per-chat sequence, never by the device clock; a pending message stays at the bottom until it gets a number. History loads in pages by sequence, newest first.
Gaps, offline and background
Every update carries the chat's sequence number. A jump means something was missed: ask for everything after the last number you have, and send the same request after every reconnect.
Offline, sending only appends to the outbox, replayed in order with the same client ids when the connection returns.
A notification service extension (mutable-content: 1) decrypts the payload, writes the message into the database in the App Group container and adds the sender's avatar through INSendMessageIntent, so the chat is already there when the user taps.
The badge comes from the server's unread count in aps.badge: only the server knows what was read on other devices.
The courier's app sends throttled location fixes to the server, which matches them to the road and pushes them to the customer over a socket while the map is open. Once the customer leaves the app or locks the phone, a Live Activity updated by push takes over: a suspended app receives nothing.
The courier's phone
CLLocationUpdate.liveUpdates(.otherNavigation) (iOS 17) with a CLBackgroundActivitySession, or CLLocationManager with allowsBackgroundLocationUpdates, keeps fixes coming through a shift; both need the location background mode.
Send every few seconds or every few dozen metres, whichever comes first, in batches over HTTPS, and drop fixes with a poor horizontalAccuracy. GPS and the radio are the battery cost, so send less while the courier stands still.
Without a network, fixes queue on disk and upload in order later; the live view only needs the latest one.
Server to customer
While the order screen is open: a WebSocket or server-sent events for that order, an update every few seconds, with the ETA computed on the server.
The map animates the marker along the route between fixes instead of jumping, and shows "updated 40 s ago" when updates stop, rather than a frozen marker that looks live.
After every reconnect, fetch the latest position before applying the stream.
Backgrounded or locked
The socket dies seconds after the customer leaves the app. Start a Live Activity when the order is accepted: it sits on the Lock Screen and in the Dynamic Island, and the server updates it with APNs pushes.
A Live Activity cannot use the network or location itself; everything it shows arrives in the push's content-state. Push what changes the picture (ETA minutes, status, a coarse distance), not every fix: high-priority pushes are budgeted per hour, and NSSupportsLiveActivitiesFrequentUpdates only raises that budget.
Milestones the user must not miss, like "the courier is at your door", also go out as alert pushes.
Privacy
Show the courier's position only between pickup and delivery, and keep it coarse until they are close.
Features call one typed track API that returns at once; events land in a disk-backed queue, leave in compressed batches with a unique id each, and fan out to vendor adapters behind a protocol. Consent and sampling are decided once, at the front of that pipeline.
The API
Typed events (an enum, or a struct per event) instead of string names, so a typo fails the build and one registry documents every property.
track never blocks: it hands the event to an actor or a serial queue and returns. Nothing in the layer runs before the first frame.
Buffering and delivery
Append each event to an on-disk queue (SQLite or an append-only file) before trying to send it. An in-memory buffer loses everything in a crash, and the crash is the event you want most.
Flush by count (50 events), by time (30 s) and when the scene enters the background, under beginBackgroundTask; gzip the batch.
Every event carries a UUID, the device time, a per-install sequence number and the session id. The server drops retried duplicates by id and corrects clock skew against its own receive time.
Cap the queue at a few thousand events and drop the oldest low-value ones first when the user stays offline for a week.
Sampling
Sample users, not events: hash the install id once, so a sampled user's whole funnel is either in or out.
Never sample purchases or errors, and tag each event with its rate so dashboards can scale counts back up.
Consent
Where consent is required (GDPR, for example), hold events in memory until the user answers and discard them on a no.
App Tracking Transparency is a separate switch: it governs the IDFA and tracking across other companies' apps, not your own product analytics.
Declare what you collect in PrivacyInfo.xcprivacy, keep personal data out of event properties, and make opting out delete the queue too.
Switching vendors
Features depend on your Analytics protocol; Firebase, Amplitude or AppMetrica are sinks behind it. A migration runs both sinks for one release and compares their counts before the old one goes.
It pays off on screens product rearranges weekly (home, promos, catalogue layouts), where App Review and slow updates are the bottleneck. Old builds cope because every request says which components the build can draw, and an unknown type decodes to a fallback instead of failing the screen.
When it is worth it
Layout and content change often, the same screen ships on iOS, Android and the web, and experiments need new arrangements without a release.
Not for forms with logic, complex gestures, animation or anything performance-critical: that road ends in a slow browser and a second programming language written in JSON.
How it is built
The server sends a tree of components, each with a type, props and actions; the app maps every type to a native view from the design system.
Actions are a small closed set: open a deep link, call an endpoint, track an event. Arbitrary logic in the payload is where these systems rot.
App Review guideline 2.5.2 forbids downloading code that changes the app's features; arranging native components the binary already contains is fine.
Old versions
Each request sends what the build can render (a list of component types, or one schema version), and the server builds a layout for it. It has to keep doing so for every version above the minimum supported one.
An unknown type renders the server's fallback, or nothing; one new component must never take down the whole screen.
The last layout is cached on disk and a default ships in the bundle, so the first launch and offline still render.
Contract tests on the server render every screen for each supported schema version.
The cost
Debugging moves to the backend: attach the layout id to every crash report and analytics event, or "the home screen looks broken" has no reproduction.
As a thin host app on a shared platform layer, with a pair of modules per business: a small interface module other teams may import and an implementation only the host links. Nothing starts at launch, budgets are enforced in CI, and features ship dark behind flags on a shared release train.
Modules and boundaries
Platform: networking, auth, the design system, analytics, the router and feature flags, owned by a platform team.
Each business is two Swift packages: TaxiInterface (protocols, routes, shared models) and Taxi (screens and logic). Food opens a ride through TaxiInterface without depending on its code, so a change inside Taxi rebuilds nothing else.
The host app is the composition root: it links the implementations and registers them with the router.
Each feature has a demo app that builds only that feature and the platform, so a team iterates in seconds instead of building everything.
Ownership is explicit: CODEOWNERS per module, and the dependency rules checked in CI.
Launch and size
A feature registers a lazy factory and does nothing until it is first opened; an SDK that insists on starting at launch is wrapped and deferred.
Static linking, or mergeable libraries since Xcode 15, keeps a hundred modules from becoming a hundred dynamic frameworks for dyld to load.
Every pull request measures launch time and binary size against per-module budgets, so a regression has an owner before it ships.
Releases
One binary means one release train, for example weekly with a cut-off; teams merge behind feature flags and switch them on independently.
The App Store has no rollback, so a remote kill switch per mini-app is the only way to turn a broken feature off within minutes.
A crash in the food module still ends the taxi ride: there is no process isolation inside an iOS app, so crash rates are tracked per owning team.
Web mini-apps
Some super-apps run partner mini-apps as web content. App Review guideline 4.7 allows HTML5 and JavaScript mini-apps if the host answers for them (privacy, content moderation, in-app purchase for digital goods), and 4.7.2 forbids exposing native APIs to them without Apple's permission.