Swift Interview Questions Overview
iOS engineering roles at Apple, Airbnb, Uber, and fintech companies require deep Swift knowledge. Interviewers test memory management (ARC), value vs reference semantics, protocol-oriented programming, concurrency, and SwiftUI architecture. This guide covers the questions that appear most frequently.
Memory Management: ARC and Retain Cycles
How does ARC (Automatic Reference Counting) work?
ARC automatically manages object lifetimes by tracking the reference count of each class instance. When you create a class instance, ARC sets its count to 1. Each additional strong reference increments the count. When a reference goes away (variable goes out of scope, property is set to nil), ARC decrements the count. When the count reaches 0, ARC deallocates the instance and calls deinit. ARC only applies to class (reference type) instances — structs and enums (value types) are copied, not reference-counted.
What is a retain cycle and how do you break it?
A retain cycle occurs when two objects hold strong references to each other, preventing ARC from ever deallocating either. Classic example: a view controller holds a closure strongly; the closure captures self strongly. Neither can be deallocated.
// Retain cycle:
class NetworkManager {
var onComplete: (() -> Void)?
}
class ViewController: UIViewController {
let manager = NetworkManager()
func setup() {
// Captures self strongly — retain cycle!
manager.onComplete = {
self.updateUI()
}
}
}
// Fix: weak or unowned capture
manager.onComplete = { [weak self] in
self?.updateUI() // optional — self may be nil if VC was deallocated
}
Use weak when the referenced object might be deallocated while the closure is alive (use optional chaining). Use unowned when you guarantee the referenced object outlives the closure (crashes if wrong — use only when certain).
Value Types vs Reference Types
Structs, enums, and tuples are value types — assignment creates an independent copy. Classes are reference types — assignment copies the pointer, so both variables refer to the same object. Swift uses copy-on-write (COW) optimization for standard library value types (Array, Dictionary, String): the copy is deferred until one of the copies is mutated, avoiding unnecessary copies for read-only uses.
var a = [1, 2, 3] // array allocated
var b = a // no copy yet — both share storage
b.append(4) // copy-on-write triggers: b gets its own storage
// a is still [1, 2, 3]; b is [1, 2, 3, 4]
Optionals
Swift’s optional type (T?) represents a value that may be absent (nil). Optionals are enums: Optional = .some(T) | .none. Unwrapping patterns:
- Optional binding:
if let name = optionalName { ... }— safe, nil-safe branch - Guard let:
guard let name = optionalName else { return }— early exit, name available after guard - Optional chaining:
user?.address?.city— returns nil if any link is nil - Nil coalescing:
name ?? "Unknown"— provides a default for nil - Force unwrap:
name!— crashes at runtime if nil; avoid except for programmer-error cases
Protocols and Protocol-Oriented Programming
Swift protocols define a contract (methods, properties) without implementation. Unlike abstract classes, any type (struct, class, enum) can conform. Protocol extensions provide default implementations, enabling code reuse without inheritance. Key protocols to know: Equatable (== operator), Hashable (for use in Set/Dictionary), Comparable (, sorting), Codable (Encodable + Decodable, JSON serialization), Identifiable (id property for SwiftUI ForEach).
protocol Drawable {
func draw()
var boundingBox: CGRect { get }
}
extension Drawable {
// Default implementation — all conformers get this for free
func highlight() {
print("Highlighting (boundingBox)")
}
}
struct Circle: Drawable {
var radius: CGFloat
var boundingBox: CGRect { CGRect(x: 0, y: 0, width: radius*2, height: radius*2) }
func draw() { print("Drawing circle with radius (radius)") }
}
Swift Concurrency: async/await and Actors
How does Swift async/await differ from GCD (Grand Central Dispatch)?
GCD uses completion callbacks, which compose poorly (callback hell, error propagation is manual). Swift async/await uses structured concurrency: an async function suspends without blocking its thread, allowing the thread to do other work. The compiler enforces that you handle errors. async let and TaskGroup enable structured parallel work. The runtime uses a cooperative thread pool sized to CPU count — no oversubscription.
// GCD (callback-based):
func fetchUser(id: Int, completion: @escaping (Result) -> Void) {
URLSession.shared.dataTask(with: userURL(id)) { data, _, error in
if let error = error { completion(.failure(error)); return }
completion(.success(try! JSONDecoder().decode(User.self, from: data!)))
}.resume()
}
// Swift concurrency (structured):
func fetchUser(id: Int) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: userURL(id))
return try JSONDecoder().decode(User.self, from: data)
}
// Parallel fetches:
async let user = fetchUser(id: 1)
async let posts = fetchPosts(userId: 1)
let (u, p) = try await (user, posts)
What is an Actor in Swift?
An actor is a reference type that protects its mutable state from concurrent access. Only one task can execute inside an actor at a time — the actor serializes access to its properties. Instead of using locks or queues, you use actors and await to access them from outside. The @MainActor annotation ensures code runs on the main thread, replacing DispatchQueue.main.async.
actor BankAccount {
private var balance: Decimal = 0
func deposit(_ amount: Decimal) {
balance += amount
}
func withdraw(_ amount: Decimal) throws {
guard balance >= amount else { throw BankError.insufficientFunds }
balance -= amount
}
}
// Usage:
let account = BankAccount()
await account.deposit(100)
try await account.withdraw(50)
Combine Framework
Combine is Apple’s reactive framework (similar to RxSwift). Publishers emit values over time; Operators transform them (map, filter, flatMap, debounce, combineLatest); Subscribers receive values (sink, assign). Key use cases: form validation (combining multiple text field publishers, enabling a button when all valid), API calls with retry logic, and live search with debounce.
// Search with 300ms debounce, deduplication, and network fetch:
searchTextField.textPublisher
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { query in
API.search(query: query)
.catch { _ in Just([]) }
}
.receive(on: DispatchQueue.main)
.assign(to: .results, on: self)
.store(in: &cancellables)
SwiftUI Architecture
SwiftUI uses a declarative, reactive model: the view is a function of state. When state changes, SwiftUI re-renders affected views. State management options:
- @State: local view state (owned by the view)
- @Binding: a writable reference to a parent’s @State
- @StateObject / @ObservedObject: a reference-type observable object (ViewModel); @StateObject owns the lifecycle, @ObservedObject receives it from outside
- @EnvironmentObject: dependency injection for objects passed through the view hierarchy without explicit prop drilling
MVVM is idiomatic in SwiftUI: ViewModel is an ObservableObject with @Published properties; the View binds to these properties. Business logic lives in the ViewModel; the View is as thin as possible.
Key Interview Takeaways
- ARC manages class lifetimes; break retain cycles with [weak self] in closures
- Value types (struct) copy on assignment — use for simple data; classes for shared mutable state
- Prefer optional binding / guard let over force unwrap
- Protocols + extensions enable code reuse without inheritance (protocol-oriented programming)
- async/await + actors replace GCD callbacks and locks with structured, composable concurrency
- SwiftUI state: @State for local, @ObservedObject for external ViewModel, @EnvironmentObject for DI
Frequently Asked Questions
What is a memory leak in iOS and how do you detect one?
A memory leak occurs when objects are allocated but never deallocated because they are kept alive by strong reference cycles (retain cycles). In ARC, the most common cause is a closure capturing self strongly while being stored on an object that self holds. Detection tools: (1) Xcode Memory Graph Debugger: pause the app, click the memory graph button — it shows all live objects and their reference chains. Leaked objects appear highlighted. (2) Instruments > Leaks template: records allocations over time; the Leaks instrument runs periodic leak detection and marks objects that are unreachable but still allocated. (3) deinit logging: add print("deinit") to suspect classes — if deinit is never called after you expect the object to be released, a retain cycle exists. Fix: audit all closures that capture self; use [weak self] capture lists. For delegate patterns, declare delegate properties as weak var delegate: SomeDelegate?.
What is the difference between @escaping and non-escaping closures in Swift?
A non-escaping closure (default) is guaranteed to be called before the function returns. The compiler can optimize memory for non-escaping closures — they can refer to self without capturing it. An @escaping closure may be called after the function returns (stored in a property, passed to an asynchronous operation, added to a dispatch queue). Since an @escaping closure outlives the function call, it must capture any values it needs, including self. When a closure is @escaping, the compiler requires explicit self.property access (instead of just property) to make the capture visible and remind developers to check for retain cycles. Example: URLSession.dataTask(completionHandler:) takes an @escaping closure because the network request completes asynchronously after the function returns. Array.forEach takes a non-escaping closure because it calls the closure immediately for each element before returning.
How does Swift's structured concurrency differ from DispatchQueue?
DispatchQueue-based concurrency has several pain points: nested completion callbacks create callback hell; error handling requires manual propagation through Result types; cancellation must be implemented manually; it is easy to accidentally over-subscribe threads (creating more DispatchQueue work than CPU cores, causing excessive context switching). Swift structured concurrency (async/await, Task, TaskGroup) addresses all of these. async functions suspend without blocking their thread — the thread is available for other work during the suspension. Errors propagate naturally with try/await using the normal Swift error handling model. Tasks form a tree: a parent task cannot complete until all its child tasks complete, making lifecycle management automatic. Cancellation is cooperative and propagates through the task tree — checking Task.isCancelled in loops is the idiom. The runtime uses a fixed-size cooperative thread pool (sized to CPU core count) instead of growing unboundedly. async let enables concurrent sub-tasks with the same clean syntax. This model is safer, more composable, and more efficient than manual DispatchQueue management.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is a memory leak in iOS and how do you detect one?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A memory leak occurs when objects are allocated but never deallocated because they are kept alive by strong reference cycles (retain cycles). In ARC, the most common cause is a closure capturing self strongly while being stored on an object that self holds. Detection tools: (1) Xcode Memory Graph Debugger: pause the app, click the memory graph button — it shows all live objects and their reference chains. Leaked objects appear highlighted. (2) Instruments > Leaks template: records allocations over time; the Leaks instrument runs periodic leak detection and marks objects that are unreachable but still allocated. (3) deinit logging: add print(“deinit”) to suspect classes — if deinit is never called after you expect the object to be released, a retain cycle exists. Fix: audit all closures that capture self; use [weak self] capture lists. For delegate patterns, declare delegate properties as weak var delegate: SomeDelegate?.”
}
},
{
“@type”: “Question”,
“name”: “What is the difference between @escaping and non-escaping closures in Swift?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A non-escaping closure (default) is guaranteed to be called before the function returns. The compiler can optimize memory for non-escaping closures — they can refer to self without capturing it. An @escaping closure may be called after the function returns (stored in a property, passed to an asynchronous operation, added to a dispatch queue). Since an @escaping closure outlives the function call, it must capture any values it needs, including self. When a closure is @escaping, the compiler requires explicit self.property access (instead of just property) to make the capture visible and remind developers to check for retain cycles. Example: URLSession.dataTask(completionHandler:) takes an @escaping closure because the network request completes asynchronously after the function returns. Array.forEach takes a non-escaping closure because it calls the closure immediately for each element before returning.”
}
},
{
“@type”: “Question”,
“name”: “How does Swift’s structured concurrency differ from DispatchQueue?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “DispatchQueue-based concurrency has several pain points: nested completion callbacks create callback hell; error handling requires manual propagation through Result types; cancellation must be implemented manually; it is easy to accidentally over-subscribe threads (creating more DispatchQueue work than CPU cores, causing excessive context switching). Swift structured concurrency (async/await, Task, TaskGroup) addresses all of these. async functions suspend without blocking their thread — the thread is available for other work during the suspension. Errors propagate naturally with try/await using the normal Swift error handling model. Tasks form a tree: a parent task cannot complete until all its child tasks complete, making lifecycle management automatic. Cancellation is cooperative and propagates through the task tree — checking Task.isCancelled in loops is the idiom. The runtime uses a fixed-size cooperative thread pool (sized to CPU core count) instead of growing unboundedly. async let enables concurrent sub-tasks with the same clean syntax. This model is safer, more composable, and more efficient than manual DispatchQueue management.”
}
}
]
}