Only 5.7% of professional developers report doing extensive Swift work, according to Stack Overflow's 2025 Developer Survey, the smallest share of any language with a real mobile install base. That's an odd number for a language that has shipped on every iPhone sold since 2014. It also means iOS interview questions draw from a narrower, more specialized pool of concepts than a generic "software engineer" loop tests, and interviewers lean on that specialization. The questions below test differently than a general backend interview does.
Two things changed the shape of this interview over the last two years. SwiftUI stopped being the newer, riskier framework, most greenfield teams start there by default now, and interviewers ask you to reason about state and re-rendering instead of reciting view controller lifecycle methods. Swift 6 also made strict concurrency the default language mode, so the iOS interview questions about actors, Sendable, and data races moved from "nice to know" to baseline for mid-level roles and up.
Here's a take that might be wrong: asking a candidate to recite the UIViewController lifecycle from memory tells you almost nothing useful in 2026. Most engineers who've shipped an app for a year can list viewDidLoad, viewWillAppear, and viewDidAppear in order. What actually separates a strong answer from a memorized one is whether they can explain why a network call placed in viewDidLoad instead of viewWillAppear caused a specific bug somewhere. I still see interviewers reward the recitation over the reasoning, and I think that's backwards.
Swift fundamentals: optionals, structs vs. classes, and value semantics
This is where most loops start, and it's the section candidates underprepare for most because they assume it's "too basic." It isn't. Interviewers use it to filter out candidates who've memorized SwiftUI tutorials without understanding the language underneath.
Easy questions
15An optional (String?) represents a value that might be nil, and the compiler forces you to unwrap it, through if let, guard let, or optional chaining. An implicitly unwrapped optional (String!) tells the compiler "trust me, this will have a value by the time you read it," and skips the unwrapping requirement, at the cost of a crash if you're wrong.
Force-unwrapping is acceptable in narrow cases: @IBOutlet properties guaranteed to exist once a view loads, or a value you've already checked two lines above. "I force-unwrap because I'm lazy" is a worse answer than "I force-unwrap here because this property is set in viewDidLoad before anything else can read it."
ARC inserts retain and release calls at compile time based on static analysis. Each object tracks a reference count and deallocates the instant that count hits zero, deterministically, with no pause. A tracing garbage collector instead walks the object graph periodically at runtime to find unreachable objects, reclaiming memory in a batch that can introduce unpredictable pause times.
Interviewers want the trade-off, not just the mechanism. ARC's determinism is why deinit is reliable enough to close files or cancel requests. The cost: ARC can't detect cycles on its own, a tracing collector can.
Weak self becomes an optional inside the closure, you need a guard let self or self?. everywhere. Unowned self stays non-optional and reads cleaner, but if the closure somehow runs after self has deallocated, it crashes instead of silently doing nothing.
The safer default is weak. Unowned self is defensible for a closure you're certain fires only while the owning object is alive, a synchronous animation completion block on the view that created it, for example. Interviewers are usually checking whether you've thought about it, rather than copy-pasting whichever one Xcode's autocomplete suggested.
A serial queue runs one task at a time, in the order submitted, and is the right default for protecting shared mutable state without a lock. A concurrent queue can run multiple tasks in parallel, up to however many threads the system decides to allocate, useful for independent work like decoding several images at once.
Honest note: I don't have great intuition for exactly how many threads the system spins up under load, it's an undocumented detail that shifts between OS versions. What matters for the interview is knowing which queue type you'd reach for and why, not the exact count.
Add a CodingKeys enum conforming to CodingKey, and map each Swift property name to its actual JSON key.
struct User: Codable {
let fullName: String
let userAge: Int
enum CodingKeys: String, CodingKey {
case fullName = "full_name"
case userAge = "age"
}
}The follow-up usually reaches for the async networking layer on top of this: fetch a user with URLSession's async API, decode it, and handle a non-200 response without crashing.
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(User.self, from: data)
}Mostly no, but conceptually yes. Few teams in 2026 write pure SwiftUI with zero UIKit interop, view controller wrapping through UIViewControllerRepresentable shows up more than job postings suggest. You won't need to memorize UIKit APIs, but the view controller lifecycle helps you reason about that interop layer.
Increasingly yes, for mid-level roles and above. Junior loops still lean on GCD and basic async/await. A posting that mentions "Swift 6" or "strict concurrency" usually means at least one question on Sendable or actor isolation.
Fewer than a general backend SWE loop. Most iOS interviews weight Swift-specific and framework questions more heavily than generic algorithms, though a phone screen with one medium-difficulty array or string problem is still common at larger companies.
Sometimes, but lightly. A junior loop is more likely to ask "what's the difference between GCD and async/await" than to push into actor reentrancy or Sendable conformance edge cases. Those deeper questions are more common from mid-level up.
Confusing weak and unowned in a way that suggests they picked one arbitrarily rather than reasoning about the lifetimes involved. "I use weak everywhere to be safe," without being able to explain the trade-off, gets pushed harder than a slightly less polished answer that reasons through it.
let creates a constant, you can assign it once and the compiler stops you from touching it again. var is a normal mutable variable. Both are strongly typed, the difference is entirely about whether reassignment is allowed, not about value versus reference semantics.
Swift defaults to let in its own API guidelines and Xcode's autocomplete because immutable state is easier to reason about. A constant can't be changed by some other part of the code while you're not looking, which matters a lot once you have multiple closures or threads holding a reference to the same object. In practice you reach for var when something genuinely needs to change over its lifetime, a loop counter, an accumulator, a property that tracks state on a view controller, everything else should default to let.
Optional chaining, the ?. syntax, lets you call a method or access a property on an optional without manually unwrapping it first. If any link in the chain is nil, the whole expression short-circuits to nil instead of crashing, and the result type itself becomes optional. So user?.profile?.avatarURL is nil if user is nil, if profile is nil, or if avatarURL itself happens to be nil.
Nil-coalescing, ??, does something different. It takes an optional and gives you back a non-optional value by supplying a default when the left side is nil. The two are often used together: user?.profile?.displayName ?? "Guest" chains through the optionals safely and then falls back to a default so the rest of your code can work with a plain String instead of an Optional
private restricts access to the enclosing declaration and any extensions of that type in the same file. fileprivate opens it up to everything else in that file, useful when you split a type across an extension for organization but still want to keep the API away from the rest of the module. internal is the default, visible anywhere inside the same module but invisible to anything importing the module from outside. public makes a symbol visible outside the module, but other modules can't subclass a public class or override a public method. open goes one step further and explicitly allows external subclassing and overriding.
In day to day work I default to private for implementation details and internal for anything the rest of the app needs, and only reach for public or open when I'm actually building a framework other teams or apps will consume, since open in particular is a real commitment about your API's stability.
AppDelegate owns process level events, things that happen once no matter how many windows are on screen: launching, registering for push notifications, background fetch, handling a URL scheme when the app wasn't already running. SceneDelegate, introduced in iOS 13, owns the lifecycle of a single UI instance, a scene, things like foreground/background transitions and connecting a window to a scene session.
The split exists because iPad started supporting multiple windows of the same app open at once, so lifecycle events like "entered background" needed to apply to one window rather than the whole process. If you're not supporting multiple windows, the practical effect is small, you'll set up your root view controller in scene(_:willConnectTo:options:) instead of application(_:didFinishLaunchingWithOptions:), and AppDelegate stays responsible for the process level callbacks it always handled.
A table view with thousands of rows would be prohibitively expensive to render if it allocated a brand new cell, with its full view hierarchy, for every single row. Instead it keeps a small pool of cells roughly equal to what fits on screen plus a couple extra, and as cells scroll offscreen it recycles them for the next row that scrolls into view. dequeueReusableCell(withIdentifier:) pulls one of those recycled instances back out instead of allocating a new one.
The catch is that a recycled cell still has whatever state you set on it the last time it was used. If you don't reset the image view, the accessory type, or cancel a pending image download in cellForRowAt or prepareForReuse, you'll see a flash of the wrong content, the classic bug where a cell briefly shows someone else's profile picture while the real one loads. Every stateful property you set conditionally needs an explicit else branch that clears it.
Medium questions
25Structs are value types: assigning or passing one copies its data. Classes are reference types: assigning or passing one shares the same instance. Structs get a free memberwise initializer and can't inherit, though they can conform to protocols. Only classes support inheritance, Objective-C interop, and deinitializers.
Default to struct unless you need shared mutable state, identity (two instances that are "the same" object even if their values match), or inheritance. The usual follow-up: name a case where a struct is the wrong choice. A view model shared across three screens that all need live updates is the classic answer, since a struct copy would silently desync.
Passing a struct copies its value into the function's scope. Mutating that copy inside the function (or after assigning it to a new variable) never touches the original. Passing a class instance passes a reference. Both the caller and the function now point at the same object in memory, so a mutation inside the function is visible to the caller immediately.
struct Point {
var x: Int
var y: Int
}
class Location {
var x: Int
var y: Int
init(x: Int, y: Int) {
self.x = x
self.y = y
}
}
var p1 = Point(x: 0, y: 0)
var p2 = p1
p2.x = 10
print(p1.x) // 0, p1 is untouched
let loc1 = Location(x: 0, y: 0)
let loc2 = loc1
loc2.x = 10
print(loc1.x) // 10, both variables point at the same instanceThis really tests whether you understand why SwiftUI leans on structs for views. If views were reference types, diffing two view trees would need deep object comparison instead of cheap value comparison.
Array, Dictionary, Set, and String are structs at the language level, but internally they're backed by a class holding the actual buffer. Assigning one to a new variable just copies a reference to that shared buffer, an O(1) operation. The real duplication only happens the moment you mutate one of the copies, when Swift checks if the buffer is uniquely referenced and, if not, allocates a fresh one first.
This separates candidates who've read the Swift source from candidates who've only used arrays. A common follow-up: "are two arrays sharing a buffer safe to read concurrently from different threads?" Yes, reads are fine since neither side mutates. Concurrent mutation from two threads is not, and that's a separate bug entirely.
A retain cycle happens when two objects hold strong references to each other. Neither reference count ever reaches zero, so neither deinit ever runs, and both objects leak for the life of the app.
class Owner {
var pet: Pet?
deinit { print("Owner deallocated") }
}
class Pet {
var owner: Owner?
deinit { print("Pet deallocated") }
}
var owner: Owner? = Owner()
var pet: Pet? = Pet()
owner?.pet = pet
pet?.owner = owner
owner = nil
pet = nil
// Neither deinit ever prints. Each instance still holds
// a strong reference to the other through the local properties.Most candidates first notice this because a view controller's deinit never fires, memory climbs in Instruments on every push-and-pop of a screen. Interviewers often ask you to fix the snippet above, the answer is marking one side (usually the "child," here Pet.owner) as weak.
Use weak when the referenced object can legitimately become nil during the reference's lifetime, weak references are optional and zero themselves out on deallocation. Use unowned only when you're certain the referenced object will always outlive the reference, it isn't optional and it doesn't zero itself.
Get it wrong and an unowned reference to a deallocated object crashes the instant you touch it, not a graceful nil. "I default to weak unless I have a provable reason the lifetimes are tied together" beats confidently picking unowned everywhere for the smaller syntax.
A closure passed as a function parameter is non-escaping by default, the compiler guarantees it's called or discarded before the function returns. Mark a parameter @escaping when the closure needs to be stored or called after the function has already returned, the standard case being a completion handler that fires once a network response arrives.
Non-escaping closures can sometimes skip heap allocation entirely; escaping ones can't, since they need to outlive the current stack frame. Spotting a missing @escaping in a compiler error is a genuine, common first-week mistake, and interviewers know it.
If a closure references self and that closure is itself stored as a property on the same object, you get a cycle: the object retains the closure, and the closure's capture of self retains the object right back.
class ImageLoader {
var onComplete: (() -> Void)?
func load() {
onComplete = { [weak self] in
guard let self = self else { return }
self.updateUI()
}
}
func updateUI() {
// update image view here
}
}The capture list [weak self] breaks the cycle. Without it, this exact pattern (a stored closure property that captures self) is one of the most common leaks reported in App Store apps that crash under memory pressure after extended use.
loadView creates the view hierarchy (skip it unless you're building views entirely in code). viewDidLoad fires once, good for one-time setup like configuring a table view's data source. viewWillAppear fires every time the view is about to become visible, good for refreshing data that changed while the screen was off-screen. viewDidAppear fires once the view is fully on screen, right for starting animations or analytics. viewWillDisappear and viewDidDisappear mirror the pair on the way out.
The usual scenario follow-up: a user backgrounds the app mid-screen and returns 10 minutes later, which method re-runs, and what should you refresh there? viewWillAppear, since viewDidLoad only fires once for the entire lifetime.
SwiftUI tracks which property wrappers a view's body reads (@State, @Binding, @ObservedObject, @EnvironmentObject) and re-invokes body only when one of those tracked values changes. It then diffs the new view tree against the old one and applies the minimal set of real UIKit updates, it doesn't rebuild the screen from scratch.
Common follow-up: since body can run many times, is it safe to put expensive work in it? No. Expensive computation belongs in onAppear, a view model, or a cached property, never in the body's evaluation path.
@State is local, private, value-type state that the view itself owns. @Binding is a two-way reference to state owned by a parent view. @ObservedObject watches an external reference-type object conforming to ObservableObject, redrawing whenever a @Published property changes, but the object's lifetime is owned by whoever creates it. @EnvironmentObject is an object injected into the view hierarchy that any descendant can read without it being passed explicitly through every initializer.
The trap most candidates miss: @ObservedObject doesn't own the object's lifecycle. If the parent recreates itself and passes a fresh instance, the observed object resets, silently losing state. @StateObject fixes that, the view that declares it owns the instance for its entire lifetime, even across parent re-renders. This one question separates tutorial-level SwiftUI knowledge from production experience.
GCD schedules closures onto queues you manage manually, and it's easy to nest closures until the code becomes hard to follow, the classic "callback pyramid." Async/await lets the compiler suspend and resume a function at await points, so asynchronous code reads top to bottom, and the compiler checks for structured cancellation and error propagation that GCD closures never got automatically.
GCD isn't obsolete, low-level dispatch queues still back a lot of the concurrency runtime. What interviewers check is whether you know async/await sits on a cooperative thread pool rather than spinning up a new OS thread per task, which matters when someone asks why 10,000 concurrent tasks don't crash the app.
Protocol-oriented programming composes behavior from multiple small protocols rather than inheriting from one base class. A type can conform to several protocols at once, something Swift's single-inheritance model doesn't allow, and structs and enums, which can't inherit at all, still adopt protocols and gain shared behavior through extensions.
The real trade-off: inheritance gives you a rigid hierarchy that's easy to reason about until a type needs to be two unrelated things at once (a class that's both "flyable" and "swimmable" doesn't fit single inheritance). Protocol composition handles that naturally. Honest downside, protocol hierarchies get harder to trace than a class hierarchy once a dozen small protocols stack together.
A protocol extension lets you write a method body once and have every conforming type inherit it automatically, without duplicating the code. Conforming types can still override the default if they need custom behavior.
protocol Flyable {
func fly()
}
extension Flyable {
func fly() {
print("Flapping wings")
}
}
struct Sparrow: Flyable {}
struct Penguin: Flyable {
func fly() {
print("Penguins can't actually fly")
}
}The gotcha: call fly() on a variable typed as the protocol rather than the concrete type, and if the method lives only in the extension, Swift dispatches statically off the declared type, not dynamically. That trips up even experienced candidates.
Every constraint has a priority from 1 to 1000, with 1000 marked "required." When two required constraints genuinely conflict, Auto Layout logs an "Unable to simultaneously satisfy constraints" warning and breaks one, though which one isn't something to rely on. Make the trade-off explicit yourself, drop one priority to 999 so it's clear which should yield.
This differs from an ambiguous layout, the opposite problem, not enough constraints to determine a unique size and position, rather than too many conflicting ones. Candidates who conflate the two usually haven't spent real time debugging Auto Layout console warnings.
In the classic UIKit MVC setup, the view controller ends up doing three jobs at once: reacting to UIKit events, holding presentation logic like formatting a date or deciding which label shows an error, and often talking directly to a network or persistence layer. That's how you get the "massive view controller" problem, a 900 line file that's nearly impossible to unit test because half its logic is entangled with UIKit lifecycle calls.
MVVM pulls the presentation logic and state out into a separate ViewModel object that has no import of UIKit at all, it just exposes plain values and closures or Combine publishers. The view controller's only job becomes binding those values to labels and buttons and forwarding user actions back to the ViewModel. The real payoff is testability, you can instantiate a ViewModel in a unit test, call a method, and assert on its published state without ever touching a view hierarchy.
It's not free though. In UIKit, MVVM requires you to write the binding code yourself, closures or Combine subscriptions wiring ViewModel state to view updates, and it's easy for a team to just move all the same complexity into a "massive ViewModel" if they're not disciplined about keeping it free of UI concerns too.
A property wrapper is a struct or class annotated with @propertyWrapper that defines a wrappedValue property. When you write @Clamped var health = 150, the compiler generates a private backing property of type Clamped that stores the actual instance, and rewrites every read and write of health to go through that instance's wrappedValue getter and setter instead of touching a plain stored property directly.
Here's roughly what that looks like written out by hand:
@propertyWrapper
struct Clamped {
private var value: Int
private let range: ClosedRange<Int>
init(wrappedValue: Int, _ range: ClosedRange<Int>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Int {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
}
struct Player {
@Clamped(0...100) var health = 150 // stored as 100
}Every assignment to health runs through the setter's clamping logic automatically, which is the whole point, the caller gets ordinary property syntax while the wrapper enforces an invariant behind the scenes. If the wrapper also defines a projectedValue, you get access to it with the $ prefix, which is how @Published exposes its Combine publisher alongside the plain value.
willSet and didSet attach to a stored property, the value is genuinely held in memory, the observers just let you run side effects around a change, willSet runs right before the new value is committed and didSet right after. You'd use these to persist a value to UserDefaults whenever it changes, or to trigger a UI refresh, without duplicating the storage logic yourself.
A computed property has no storage of its own by default, get and set derive the value from something else entirely, maybe from other properties or from a lookup. If you want observer-like behavior on a computed property you have to write it into the setter body yourself, there's no separate willSet/didSet hook.
One gotcha worth knowing: didSet does not fire when a property is given its initial value inside its own type's initializer via the default value or a direct assignment before super.init(), it only fires on mutations that happen after the object is fully initialized. That trips people up when they expect a didSet-driven side effect to run during init and it silently doesn't.
A generic function preserves type information at compile time. Something like func firstElement
Where generics get awkward is when you want a single homogeneous collection of things that share a protocol with an associated type, you can't just write [any SomeProtocolWithAssociatedType] and store heterogeneous concrete types side by side the way you can with a plain class hierarchy, the associated type makes the protocol unusable as a plain existential in a lot of contexts. The usual workaround is a type-erasing wrapper, something like AnySequence in the standard library, a concrete struct that forwards to a closure capturing the real generic implementation, so you get a normal storable type back out.
Core Data gives you an object graph with relationships that fault in lazily, so accessing a related object only hits the store when you actually touch that property instead of eagerly loading everything. You also get change tracking, an undo manager for free, and a real query layer through NSFetchRequest and NSPredicate that can use indexes, which is a lot more than you'd want to hand roll on top of raw SQLite for anything with more than one or two related entities.
It's overkill for small, flat data, a settings blob, a short list of recent searches, a handful of cached API responses. In those cases a Codable struct written to a file or stored in UserDefaults is simpler and skips Core Data's threading rules entirely, every NSManagedObjectContext is confined to the queue it was created on, and merging changes between a background context and the main context is a real source of bugs if you're not careful. If your data model doesn't actually need relationships or faulting, plain persistence is less code and fewer footguns.
try/catch is for imperative flow where the caller genuinely needs to branch on which error occurred, you write do { try parse(data) } catch ParseError.missingField(let name) { ... } catch { ... } and handle specific cases differently. It forces the caller to deal with the error right there, either handle it or explicitly propagate it with throws on the enclosing function.
try? converts the whole expression to an optional, discarding whatever error information there was, you just get nil on failure. That's fine when you genuinely only care about success or failure and would ignore the specific error anyway, like a quick opportunistic parse in a chain of optional operations.
Result
Combine is Apple's reactive framework, publishers emit a stream of values over time and subscribers react to them, with a large set of operators for transforming, filtering, and combining those streams. async/await has taken over most of the one-shot request and response call sites that Combine used to handle awkwardly, a single network call with a single sink subscriber was always more ceremony than it needed to be compared to a simple await call.
Combine still matters for continuous streams and for composing multiple observed values together, debounce on a search field, combineLatest across a form's several inputs to decide when a submit button should be enabled, or @Published properties driving SwiftUI view updates. AsyncSequence has been closing that gap, but a lot of teams still keep Combine specifically at the UI-binding layer where its operators are genuinely more expressive, and use async/await for the underlying data fetching.
Without it, a view controller usually decides what screen comes next itself, pushing a detail view controller directly and configuring its dependencies inline. That couples the view controller to specific destinations, makes it hard to reuse the same screen in two different flows, for example a product list that's sometimes the main tab and sometimes part of an onboarding flow with different next steps, and makes navigation logic hard to test since it's buried inside UIKit event handlers.
A Coordinator is a separate object that owns those decisions. The view controller just reports that an event happened, a button was tapped, a row was selected, through a delegate protocol or a closure, and the Coordinator decides which screen to push and wires up its dependencies. That centralizes the navigation graph in one place you can reason about and lets the same screen be reused across different flows driven by different coordinators.
The tradeoff is real overhead, an extra protocol and object per flow, and for a genuinely small app with three screens it's common to see junior engineers build out a coordinator hierarchy that adds more code than it saves. It earns its keep once you have several flows that reuse the same screens in different orders or with different entry points.
UIView is the object that participates in the responder chain, handles touches, and takes part in Auto Layout. CALayer is what actually backs the rendering, every UIView has a layer property, and a lot of the properties you set on a view, cornerRadius, shadowOpacity, transform, are really just being forwarded to that backing layer for convenience.
You reach for CALayer directly when you need something UIView's API doesn't expose, a CAShapeLayer with a custom bezier path for something like a progress ring, a CAGradientLayer for a gradient background that animates smoothly, or a plain sublayer added directly to an existing view's layer when you don't need touch handling or layout participation and want to avoid the overhead of a full UIView for something purely decorative, layers are meaningfully cheaper to create than views.
These are different from a straightforward conflict between two explicit constraints of equal priority, they govern ambiguity, which view absorbs extra or missing space when the layout isn't fully determined by explicit constraints alone. Content hugging priority controls how strongly a view resists growing past its intrinsic content size, a low hugging priority means the view is happy to stretch. Compression resistance priority controls how strongly a view resists shrinking below its intrinsic content size, a low compression resistance means the view is willing to get clipped or truncated before something else does.
A concrete example: a label sitting next to a fixed-width button in a horizontal stack view. If the label's hugging priority is lower than the button's, the label stretches to absorb the leftover space while the button stays its natural size. If the label's text can get long and you don't want the button squeezed, you raise the button's compression resistance above the label's, so the label truncates instead of the button shrinking.
The classic bug is a type-ahead search that fires a request per keystroke. Because network timing isn't guaranteed, the response to an earlier, since-abandoned query can arrive after the response to a more recent one, and if you naively update the UI in every completion handler, the stale result flashes over the correct one.
The straightforward fix is to hold a reference to the current URLSessionDataTask and call cancel() on it before starting a new one:
private var currentTask: URLSessionDataTask?
func search(for query: String) {
currentTask?.cancel()
currentTask = URLSession.shared.dataTask(with: url(for: query)) { data, _, error in
// handle result
}
currentTask?.resume()
}Cancellation isn't instantaneous though, a response that's already in flight can still land in the completion handler right after you called cancel. A more bulletproof approach tags each request with an incrementing generation number and has the completion handler check that its generation still matches the latest one before touching the UI, discarding the result silently if it doesn't. That way you're not depending on cancel() actually stopping the network call in time.
Hard questions
12An actor gives you automatic mutual exclusion for its own mutable state. Only one task can touch an actor's isolated state at a time, and the compiler enforces this rather than trusting you to remember to guard every access correctly. A class with a manual lock relies entirely on discipline, miss one guarded section anywhere in the codebase and you have a silent data race.
actor Counter {
private var value = 0
func increment() {
value += 1
}
func current() -> Int {
value
}
}
let counter = Counter()
Task {
await counter.increment()
print(await counter.current())
}Worth knowing: actors are reentrant. Between two await points in the same method, another task can run and change the actor's state, so you can't assume it's what it was right before your last await. That surprises candidates who assume "actor" means fully sequential.
Sendable marks a type as safe to pass across concurrency boundaries, between actors, or between tasks on different threads. Swift 6's strict concurrency mode checks Sendable conformance at compile time for anything crossing an isolation boundary, so races that used to surface only under Thread Sanitizer at runtime now fail to compile instead.
Structs with only Sendable members conform automatically. Classes need to be immutable (constant properties, marked final) or guarded with @unchecked Sendable, an escape hatch you take responsibility for. Swift 6.2 added an "approachable concurrency" mode defaulting new code to the main actor, and some interviewers are starting to ask whether candidates have tried it.
Yes. An associated type is a placeholder the conforming type fills in, letting one protocol definition work generically across different concrete types without Swift's compiler needing to know the type up front.
protocol Container {
associatedtype Item
var items: [Item] { get set }
mutating func add(_ item: Item)
}
struct Stack<T>: Container {
var items: [T] = []
mutating func add(_ item: T) {
items.append(item)
}
}The catch: it can't be used as a standalone existential type, you can't declare var x: Container directly, since the compiler doesn't know what concrete Item to use. Constrain it with some Container or use the concrete type instead.
The GCD-era answer used a DispatchWorkItem cancelled and rescheduled on every keystroke. The current idiomatic approach uses a cancellable Task with Task.sleep, which reads cleanly and folds into structured concurrency's cancellation model instead of a separate timer.
final class SearchViewModel {
private var searchTask: Task<Void, Never>?
func onSearchTextChanged(_ text: String) {
searchTask?.cancel()
searchTask = Task {
do {
try await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await performSearch(text)
} catch {
// Task.sleep throws CancellationError when cancelled early
}
}
}
func performSearch(_ query: String) async {
// network call goes here
}
}Two things get watched here: do you cancel the previous task before scheduling a new one (miss it and every keystroke fires a network call), and do you check Task.isCancelled after the sleep rather than assume cancellation halts execution automatically. It doesn't. Cancellation in Swift's concurrency model is cooperative, your code has to check for it.
If you can reproduce it, attach Instruments' Time Profiler and look at exactly what the main thread was doing during the freeze window. The usual suspects are synchronous network or disk I/O triggered directly from a UI action, a large JSON decode running on the main thread instead of a background queue, a Core Data fetch on the main view context blocking while a background context is mid-save, or an Auto Layout pass over a huge view hierarchy all at once.
If you can't reproduce it locally, MetricKit's MXHangDiagnostic, available since iOS 13 and improved in 14, is the tool that actually helps, the OS captures a call stack for hangs happening in production and delivers it to your app as a payload without needing a user to attach a debugger. If the watchdog is actually terminating the app (you'll see a 0x8badf00d exception code in the crash report) check specifically for slow work in application(_:didFinishLaunchingWithOptions:) or scene(_:willConnectTo:), launch has a strict time budget, a few seconds, before the system kills the process outright.
The fix is almost always the same shape once you find the offending call: move the blocking work off the main thread onto a background queue or a background actor, and only hop back to the main actor to apply the final, cheap UI update once the work is done.
Retain cycles show up clearly in the Memory Graph Debugger as a genuine cycle, purple exclamation marks. A steady climb with no cycle usually means something is legitimately being allocated over and over and legitimately staying alive, just more of it than it should. Take Instruments Allocations snapshots with "Mark Generation," one at launch, one every five minutes or so, and diff the generations rather than looking at total heap size, that narrows the problem from "everything is growing" down to the specific class or buffer that's actually accumulating.
Common non-cycle culprits: a cache with no eviction policy, an NSCache without a countLimit or totalCostLimit set, or worse a plain dictionary being used as a cache that nothing ever removes entries from; full-resolution image decoding that never gets released because some other reference, a navigation stack kept alive for state restoration, a delegate reference nobody cleared, is still holding the view that owns it; or a Combine subscription or NotificationCenter observer registered once per screen visit and never cancelled, so ten visits to the same screen means ten live subscriptions all still firing.
Once the generation diff points at a specific type, the Memory Graph Debugger shows the actual retain path from a root object down to that instance, which is much faster than reasoning about ownership purely from reading the source.
Swift enforces, partly at compile time and partly at runtime, that a variable can't have two overlapping accesses where at least one of them is a mutation. This exists so that in-place mutation, the kind Array and Dictionary rely on for their copy-on-write performance, can safely assume nothing else is reading or writing that same storage in the middle of the operation.
The trigger that actually surfaces in real code is usually an inout parameter that overlaps with another access to the same variable within the same expression or call, for example calling a mutating method on a stored property while a computed property's getter for that same underlying storage is still executing higher up the call stack, or passing a struct property as inout into a function whose closure argument also reads the same outer property. The crash message reads "Simultaneous accesses ... one of which is a mutation," and it's a deliberate, immediate crash rather than silent memory corruption, which is actually the friendlier outcome, it happens exactly at the point of violation instead of manifesting as an unrelated bug three screens later.
The fix is almost always to stop trying to interleave a read and a write against the same storage inside one expression, read the value into a local first, do the mutation, and only then use the value, rather than referencing the same property from two places in a single statement.
Run the Core Animation instrument alongside the Time Profiler while scrolling on the actual older device, not the simulator, since GPU behavior doesn't simulate accurately. The question you're answering is whether you're CPU-bound, cellForRowAt or a cell's layoutSubviews doing real work per row, text sizing, date formatting, image decoding, or GPU-bound, offscreen rendering triggered by things like combining cornerRadius with masksToBounds and a shadow on the same layer, or heavy blending from stacked non-opaque views.
Turn on "Color Offscreen-Rendered Yellow" in the Simulator's debug options first, it'll visually highlight exactly which layers are forcing an offscreen render pass, that's usually the fastest path to the actual culprit rather than guessing. On an older device the CPU and GPU headroom that was masking these costs on a newer phone simply isn't there, so a rounded corner and shadow combination that was invisible in profiling on the newest iPhone shows up as dropped frames on a three year old device.
The fixes are specific once you've found the cause: precompute and cache expensive per-cell values, row heights, attributed strings, outside of cellForRowAt rather than recomputing them on every scroll pass; decode images off the main thread; and split cornerRadius masking and shadow onto two separate layers, since a shadow forces the layer to render into an offscreen buffer to compute the blur, and combining that with masksToBounds compounds the cost.
Lightweight migration, the automatic kind Core Data infers from two model versions that look similar enough, only covers a narrow set of changes: adding an optional attribute, renaming with the right renaming identifier set, changing an attribute's optionality. A genuinely breaking change, splitting one entity into two, changing a relationship's cardinality, transforming one attribute's data into a different shape entirely, needs a real mapping model instead.
Add the new model version in the .xcdatamodeld, mark it as the current version, then create an NSMappingModel, either let Xcode infer one and hand-edit the parts it got wrong in the mapping model editor, or write one fully by hand. For transformations the mapping model editor's simple attribute-to-attribute mapping can't express, splitting a fullName attribute into firstName and lastName, for instance, you write a custom NSEntityMigrationPolicy subclass and override createDestinationInstances(forSource:in:manager:) to do that transform explicitly during migration.
Test the migration against a real persistent store pulled from an old build of the app, not an empty freshly created store, since the actual failure you care about is what happens to a user's file that has three years of data and has already been through one migration before. Also treat automatic lightweight migration as something you enable only after confirming Core Data's heuristics genuinely can infer the mapping for your specific change, when they can't, it fails in ways that are hard to diagnose once it's already in production against real user data.
Start with Xcode's Memory Graph Debugger, which shows every object still alive and draws the reference chains keeping it around (purple exclamation marks flag likely leaks directly). For a longer investigation, Instruments' Leaks and Allocations templates track retain count over repeated navigation, push a screen and pop it 10 times and watch whether the instance count returns to zero.
"I'd add a print statement in deinit" isn't wrong, but it's incomplete. Interviewers push for the tooling because print-based debugging never shows you the actual retain chain, and without the chain you're guessing at the fix.
Start with the symbolicated crash report, the offending thread's backtrace tells you what code was running, but the real question is whether this is a plain dangling pointer or, far more often in an Objective-C-bridged codebase, a message sent to an object whose memory has already been reused for something else. EXC_BAD_ACCESS from a deallocated object doesn't necessarily crash the instant it's freed, it crashes later when something tries to call a method on that memory and finds garbage where a vtable or isa pointer used to be, which makes the crash location misleading, the real bug is upstream of where the crash actually happened.
To reproduce locally, enable NSZombieEnabled in the scheme's diagnostics. With zombies on, a deallocated object is swapped for a zombie proxy instead of actually being freed, so any later message to it raises a clear, immediate "message sent to deallocated instance" exception with the object's class name, instead of the same bare EXC_BAD_ACCESS. From there, Instruments' Allocations tool with "Record reference counts" turned on shows you the retain and release history for that specific object, so you can see exactly which release call dropped it to zero ahead of the later use.
If the crash is in pure Swift code with no Objective-C bridging involved, EXC_BAD_ACCESS is less likely to be a classic dangling pointer, since ARC in pure Swift rarely produces this. Look first at any withUnsafeMutableBytes, UnsafePointer, or Unmanaged usage nearby, a pointer that escapes the closure it was handed in, or a buffer that gets reallocated while something still holds a raw pointer into it, is the usual actual cause.
The local database, Core Data, Realm, or SQLite through something like GRDB, has to be the single source of truth the UI actually reads from. Every write goes to local storage first and shows up in the UI immediately, a separate sync engine pushes changes to the server asynchronously afterward, the user is never staring at a spinner waiting on network to see their own edit.
Every locally created or edited record needs a client-generated, stable identifier, a UUID rather than a server-assigned id, so records can be created offline without waiting on a round trip, plus a dirty flag or an explicit outbox table of pending operations. When connectivity comes back, the sync engine replays the outbox in order. Conflict resolution needs an explicit, decided-up-front strategy, last-write-wins keyed on a server timestamp is the simplest option and works fine for most single-user data, but for anything genuinely collaborative you want per-field merging or a vector clock so two offline edits to different fields of the same record don't clobber each other when they both eventually sync.
Failures, a 409 conflict or a validation error from the server, should route the operation back into the outbox tagged as needing resolution rather than being silently retried forever or silently dropped. The UI should surface unsynced state honestly, a small pending indicator on a record, rather than implying everything is safely saved the moment the local write happens.
Candidates practicing iOS loops through LastRoundAI's mock interview sessions show a pattern that doesn't show up in most prep guides: they can usually implement the retain cycle fix or write the actor correctly, but stumble explaining why, out loud, in real time. Typing [weak self] from muscle memory and explaining what breaks without it are two different skills, and interviewers test the second one far more.
The SwiftUI questions expose a similar gap. Candidates who learned SwiftUI from small personal projects often haven't hit the @ObservedObject-versus-@StateObject bug in production, so they reason from first principles live, which takes longer and sounds less confident even when the answer is correct.

