iOS · 12 min read

iOS coding interview: the tasks and how they are graded

What an iOS coding interview asks: the practical tasks that recur, the algorithm round in Swift, and what is graded beyond whether it compiles.

This guide is for iOS engineers with a coding round coming up who want to know what they will be asked to build. It covers how to tell a practical round from an algorithm round before the call, the tasks that recur in iOS coding interviews with what each one is really testing, two worked tasks in Swift, what is graded beyond “it compiles”, and the Swift and Xcode traps that cost points under a clock.

The one thing to take from it: in a practical iOS task the grade is mostly set in the first ten minutes, by how you shape the model and what you say about cancellation and stale results. The UI is rarely what decides it.

Practical or algorithmic: how to tell before the call

There are two formats, and preparing for the wrong one wastes the week.

Format Environment What it asks What it rewards
Practical Xcode with your screen shared, frequently on a starter project they give you Fetch, parse, display, cache; a feature on existing code Fluency with the simulator and debugger, state and cancellation, a working vertical slice
Algorithmic A shared editor that compiles Swift with no UIKit or SwiftUI available Language, concurrency and data structure problems Clean standalone Swift, complexity you can state, a trace before “done”
Take-home Your own machine, a few hours, then a review call A small app to a brief Scope discipline, tests on the logic, a README that names trade-offs

The invitation rarely says which. “App building exercise” and “build a small app” usually mean practical. “Pairing session” can mean practical work on starter code, or a code review; the pair programming guide covers how that round is run. “Algorithms and data structures, but not LeetCode” usually still means algorithm practice.

Ask the recruiter with forced choices, because an open “what should I prepare?” gets a vague answer: “Will I write new code from scratch, work in a starter project you provide, or review existing code? Should I have Xcode ready, or will we use a browser editor? Is the emphasis on finishing a feature, or on discussing decisions as I build?”

Tasks that recur

These are the practical tasks that come back again and again in iOS coding rounds. Each one hides a single question the interviewer is really asking.

Task What it is really testing The follow-up that comes next
Search as you type, without spamming the API Out-of-order responses: a slow result for “sw” must not overwrite the result for “swift” “Test the debounce without real delays.” Inject a clock.
An image loader for a scrolling feed Cache before network, and one download per URL when two cells ask at once “What happens when a cell is reused mid-load?”
A paginated feed Explicit load states, and not firing “load more” twice “The API changes from page numbers to a cursor.”
JSON to models to a list Decoding resilience: dates, missing fields, unknown enum values “One corrupt element. Does the whole feed fail?”
Token refresh under concurrency Fifty callers get a 401 at once; exactly one refresh should run “The refresh itself fails. What do the waiters see?”
A thread-safe LRU cache O(1) get and set, then the actor versus lock trade-off “It sits on a synchronous hot path. Now what?”
A rate limiter A sliding window with an injectable clock “A million calls a second.” Memory, not correctness, is now the problem.

If you can build these from a blank file in Swift, out loud, on a timer, you have covered most of what a practical round asks. The mobile live coding guide works through the rate limiter minute by minute.

Worked task: token refresh with fifty callers

The prompt: “Requests carry a bearer token. When it expires, refresh it. Many requests can fail at the same moment.”

What the interviewer is waiting for is one sentence, said before you type: “An actor alone is not enough, because another caller can enter while the first is suspended on the network, so the refresh itself gets stored as a task, and anyone who arrives mid-refresh awaits that one task.”

struct Token: Sendable { let value: String; let expiresAt: Date }

protocol AuthClient: Sendable {
    func refresh() async throws -> Token
}

actor TokenStore {
    private var token: Token?
    private var refreshing: Task<Token, Error>?
    private let client: any AuthClient

    init(client: any AuthClient) { self.client = client }

    func validToken() async throws -> Token {
        if let token, token.expiresAt > .now { return token }
        if let refreshing { return try await refreshing.value }
        let task = Task { try await client.refresh() }
        refreshing = task
        defer { refreshing = nil }
        let fresh = try await task.value
        token = fresh
        return fresh
    }
}

This type-checks in Swift 6 language mode (checked with Swift 6.3.2). What gets graded, in order:

  1. The reentrancy sentence. Naming why the actor is not enough on its own, in plain words, reads as more senior than a dense correct monologue.
  2. The failure path. The defer clears the stored task on success and on failure. Leave it out and one failed refresh poisons every caller after it, because they all await the dead task.
  3. The test. “I would stub the client with a delay, fire fifty concurrent calls, and assert one refresh.” Saying the test is half the point.

The follow-up is usually “what if the refresh returns 401 too?” That is the moment to say the session is gone and the app must route to sign-in, rather than retry forever.

Worked task: search as you type

The prompt: “Query this search API as the user types. Do not call it on every keystroke.”

The clarifying question that decides the task: “Can responses arrive out of order?” Debouncing alone does not stop a slow response to an old query from landing after a fast response to a new one.

struct City: Identifiable, Sendable { let id: String; let name: String }

protocol CitySearch: Sendable {
    func places(named term: String) async throws -> [City]
}

@MainActor
@Observable
final class SearchModel {
    private(set) var results: [City] = []
    private(set) var failed = false
    private let search: any CitySearch
    private var current: Task<Void, Never>?

    init(search: any CitySearch) { self.search = search }

    func queryChanged(_ text: String) {
        current?.cancel()
        let term = text.trimmingCharacters(in: .whitespaces)
        if term.isEmpty { results = []; return }
        current = Task {
            do {
                try await Task.sleep(for: .milliseconds(300))
                let matches = try await search.places(named: term)
                try Task.checkCancellation()
                results = matches
                failed = false
            } catch is CancellationError {
                // A newer query owns the screen now.
            } catch {
                failed = true
            }
        }
    }
}

This also type-checks in Swift 6 language mode. Say this while you write it: the sleep is the debounce. Task.sleep throws when cancelled, so typing again inside the window ends the old search before any request goes out. The checkCancellation() that follows the network await handles ordering: a task that has been replaced never writes its results. On cancellation the task does not touch the UI state at all, because the newer task owns it. Because the whole model is @MainActor, results cannot race.

The view is a TextField and a List. Write it if there is time. Describe it if there is not. Nobody fails this task for a missing view. Candidates fail it for the stale result.

The follow-up is almost always “now test the debounce”, and a hard-coded 300 milliseconds cannot be tested without waiting. Take the timing as a dependency instead: an any Clock<Duration> and a debounce Duration in the initialiser, with ContinuousClock() and 300 milliseconds as the defaults, and clock.sleep(for:) in place of Task.sleep. The test runs the same code with the window shrunk to a few milliseconds, sends three keystrokes back to back, and asserts that the stubbed service saw exactly one request. After that the ladder usually climbs: skip a query identical to the last one, replace the flags with an explicit state enum, then share the logic between screens. Knowing the next rung before it is asked is the senior version of this task.

What is graded in the practical task

Whatever the rubric calls them, interviewers score four things: problem decomposition, correctness, communication, and testing instinct. In a practical iOS task they show up like this:

  • The model before the UI. Types, states and the data flow, said out loud and nodded at, before any view code. A wrong plan costs two sentences at minute five and twenty minutes at minute thirty.
  • Where each piece of work runs. UI updates on the main actor, decoding and heavy work off it, and a sentence saying so.
  • Cancellation and stale results. Reused cells, superseded queries, a screen that disappears mid-request. This is where otherwise strong performances most often leave a gap.
  • One error path, mentioned. The network will fail. A line of error state beats a polished loading animation.
  • Flagged shortcuts. “I would inject this in production; I am hard-coding it for time” reads as judgement. The same shortcut left silent reads as a gap.
  • A trace before “done”. Walk the code with a concrete input, then name the edge cases: empty input, one element, duplicates, cancellation mid-flight. Strong candidates skip the trace more often than any other step.

The algorithm round in Swift

Nobody runs a programming contest in an iOS screen. Expect mostly hash-map work: tallying, bucketing, finding a matching pair. After that come string problems, pointer and window scans over arrays, walking a tree (in iOS that tree is usually nested JSON or a set of views), and a stack for undo or matching brackets. Know sorting well enough to quote O(n log n) and call sorted(by:) cleanly. A linked list, when one shows up, is usually hiding inside an LRU cache. Heavy graph problems and dynamic programming are uncommon in iOS loops; spend the week elsewhere unless the company is known for one generic screen across all engineering roles.

Most of the cheap mistakes are Swift-specific:

  • Lookup cost. A Set or Dictionary answers in constant time on average, while contains and first(where:) scan the array. Call either inside a loop and your O(n) answer is suddenly O(n²). If the prompt mentions “seen before” or “distinct”, start with a Set.
  • Index origin. A slice such as array[1...] still counts from the parent’s indices, not from zero. Binary search is where that usually goes wrong under time pressure.
  • Value semantics. Assigning an array gives you an independent copy, so a helper that edits its parameter does not change the caller’s array.
  • Character access. You cannot subscript a String with an Int. Turning it into [Character] first is the fast route on a timer; say that you know the index-based API exists and why you are skipping it.

Twenty to thirty problems across those categories, each solved in Swift from an empty file, out loud, on a 25-minute timer, is enough. Solving an old one again, cleanly, seven days on teaches more than three fresh problems.

Take-home vs live

A take-home trades pressure for time, and reviewers grade scope as hard as code. Spending three times the suggested hours looks like a scoping problem, not enthusiasm, and it works against you: the reviewer now judges the result as the work of those extra hours.

Use the time they state and spend it in priority order: first a core that works from end to end, next tests around the logic that carries risk, next the README, and polish only with time left over. The review leans on the README: what the app is and the command to run it, one paragraph of architecture and why it suits a problem this small, two or three trade-offs, what you would do with more time, and how long you spent. Assume a review call where every line you submitted will be questioned, including anything an AI tool wrote for you.

Traps specific to Swift and Xcode

  • Fighting the environment. If the simulator misbehaves, say so and continue with a stub. Nobody grades your Xcode setup.
  • A protocol hierarchy for a 40-minute task. Premature abstraction is scored as a judgement miss, not as thoroughness.
  • @ObservedObject for an object the view creates. It is recreated when the parent re-evaluates and loses its state.
  • Cell reuse without an identity check. Cancelling in prepareForReuse is not enough on its own; check the cell still wants this image before setting it.
  • A shared pad with no UIKit. Do not reach for UIImage in an environment where UIKit does not exist. Model the data and say where the view would go.
  • AI rules. Some rounds allow an assistant and some ban it. Ask, then comply exactly. When it is allowed, the score moves to whether you can direct and verify what it writes.

If the coding round is one of several in your loop, iOS interview books compares the books by round and by the time you have left.

What changed in 2024 to 2026

  • Swift 6.2 shipped on 15 September 2025 with what the Swift team calls approachable concurrency (Swift.org, Swift 6.2 released).
  • A module can opt in to main actor isolation by default, so its code runs on the main actor without @MainActor written on every type.
  • The new @concurrent attribute marks a function that should run concurrently, which makes it visible in the code when work leaves the actor.

Sources

What to do next

Free guide: the Phoenix Method