The iOS Interview Question That Exposes Junior Developers Every Time
The escaping vs non-escaping patterns that make your async code actually work
Interview Helper
The escaping vs non-escaping patterns that make your async code actually work

Last͏͏ month͏͏ I͏͏ was͏͏ interviewing͏͏ this͏͏ developer͏͏ who͏͏ seemed͏͏ a͏͏ perfect͏͏ candidate͏͏ on͏͏ resume.͏͏ Five͏͏ years͏͏ iOS͏͏ experience,͏͏ solid͏͏ GitHub,͏͏ could͏͏ talk͏͏ SwiftUI͏͏ like͏͏ a͏͏ piece͏͏ of͏͏ cake.͏͏ Then͏͏ I͏͏ asked͏͏ one͏͏ simple͏͏ question:
“Tell͏͏ me͏͏ about͏͏ escaping͏͏ closures.”
Blank͏͏ stare.͏͏ Complete͏͏ silence.͏͏ Finally͏͏ he͏͏ mumbled͏͏ something͏͏ about͏͏ closures͏͏ that͏͏ “escape͏͏ the͏͏ function”͏͏ and͏͏ admitted͏͏ he͏͏ just͏͏ adds͏͏ @escaping͏͏ whenever͏͏ Xcode͏͏ complains.
This͏͏ happens͏͏ way͏͏ more͏͏ than͏͏ you’d͏͏ think.͏͏ Developers͏͏ who͏͏ can͏͏ build͏͏ complex͏͏ apps͏͏ but͏͏ fall͏͏ apart͏͏ when͏͏ asked͏͏ about͏͏ closure͏͏ semantics.͏͏ And͏͏ honestly?͏͏ I͏͏ get͏͏ it.͏͏ Nobody͏͏ explains͏͏ this͏͏ stuff͏͏ properly.
But͏͏ here’s͏͏ why͏͏ it͏͏ matters:͏͏ if͏͏ you͏͏ don’t͏͏ understand͏͏ escaping͏͏ vs͏͏ non-escaping͏͏ closures,͏͏ your͏͏ async͏͏ code͏͏ is͏͏ probably͏͏ a͏͏ mess.͏͏ Memory͏͏ leaks,͏͏ retain͏͏ cycles,͏͏ mysterious͏͏ crashes͏͏ that͏͏ only͏͏ happen͏͏ in͏͏ production.
Let͏͏ me͏͏ fix͏͏ that͏͏ for͏͏ you.
📺͏͏ Coming͏͏ soon͏͏ to͏͏ Swift͏͏ Pal:͏͏ A͏͏ complete͏͏ video͏͏ breakdown͏͏ of͏͏ closure͏͏ patterns͏͏ and͏͏ interview͏͏ prep͏͏ at͏͏ **https://youtube.com/@swift-pal**
🚩 The Red Flags I See Everywhere
First, let me show you some closure code that makes me cringe. This is real code I’ve seen in production apps:
class NetworkManager {
var completionHandler: ((Data?) -> Void)?
func fetchUser(completion: @escaping (User?) -> Void) {
// Red flag #1: Always using @escaping without understanding why
URLSession.shared.dataTask(with: URL(string: "https://api.example.com/user")!) { data, _, _ in
// Red flag #2: Strong reference to self in async closure
self.completionHandler = { _ in
let user = self.parseUser(from: data)
completion(user)
}
// Red flag #3: Calling completion handler without dispatch
DispatchQueue.main.async {
completion(self.parseUser(from: data))
}
}.resume()
}
func parseUser(from data: Data?) -> User? {
// Parsing logic
return nil
}
}
class ProfileViewController: UIViewController {
@IBOutlet weak var nameLabel: UILabel!
let networkManager = NetworkManager()
override func viewDidLoad() {
super.viewDidLoad()
// Red flag #4: Creating retain cycles without realizing it
networkManager.fetchUser { user in
self.nameLabel.text = user?.name
self.updateUI(with: user)
}
}
func updateUI(with user: User?) {
// UI update logic
}
}This code compiles fine. It might even work sometimes. But it’s a time bomb.
You’ve got retain cycles everywhere, completion handlers that never get cleaned up, and UI updates happening on background threads. The developer learned closures as “blocks of code you pass around” without understanding the memory implications.
🎯 Escaping vs Non-Escaping: What’s Really Happening
Most tutorials explain escaping closures as “closures that escape the function.” Technically true, completely useless.
Here’s what’s actually going on — it’s about timing and memory management:
// NON-ESCAPING: Closure executes before function returns
func processImmediately<T>(items: [T], processor: (T) -> T) -> [T] {
// Closure is called synchronously and completes before function returns
return items.map(processor)
// When this function ends, the closure is guaranteed to be done
}
// ESCAPING: Closure may execute after function returns
func processLater<T>(items: [T], processor: @escaping (T) -> T) {
// Closure is stored and will be called later
DispatchQueue.global().async {
let processedItems = items.map(processor)
print("Processed: \(processedItems)")
}
// Function returns immediately, but closure runs later
}The key insight: non-escaping closures don’t need memory management because they complete before the function returns. Escaping closures might outlive their creator, so Swift has to carefully manage what they capture.
Real iOS Examples
Here’s how this plays out in actual app development:
class DataProcessor {
// NON-ESCAPING: Synchronous transformation
func transform<T>(data: [T], using transformer: (T) -> T) -> [T] {
// Closure executes immediately and synchronously
return data.map(transformer)
}
// ESCAPING: Asynchronous operation
func fetchAndTransform<T>(url: URL, transformer: @escaping (Data) -> T?, completion: @escaping (T?) -> Void) {
// Both closures will execute after this function returns
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data else {
completion(nil)
return
}
let result = transformer(data)
completion(result)
}.resume()
// Function returns immediately, closures execute later
}
}
// Usage demonstrates the difference
let processor = DataProcessor()
// Non-escaping: Executes immediately
let numbers = [1, 2, 3, 4, 5]
let doubled = processor.transform(data: numbers) { $0 * 2 }
print(doubled) // [2, 4, 6, 8, 10] - available immediately
// Escaping: Executes asynchronously
processor.fetchAndTransform(
url: URL(string: "https://api.example.com/data")!,
transformer: { data in
return try? JSONDecoder().decode([String].self, from: data)
},
completion: { result in
print("Async result: \(result)")
}
)
// Function returns immediately, but completion runs later💀 Memory Management Nightmares
Now for the fun part — how escaping closures destroy your app’s memory management.
The Classic Retain Cycle
This pattern shows up in every codebase I review:
class UserProfileManager {
var currentUser: User?
var profileUpdateHandlers: [(User) -> Void] = []
// ❌ DANGEROUS: Creates retain cycles
func observeProfileUpdates() {
NetworkService.shared.onUserUpdate { user in
// Strong reference to 'self' in escaping closure
self.currentUser = user
self.notifyHandlers(user)
self.updateCache(user)
}
}
func addProfileUpdateHandler(_ handler: @escaping (User) -> Void) {
profileUpdateHandlers.append(handler)
}
private func notifyHandlers(_ user: User) {
profileUpdateHandlers.forEach { $0(user) }
}
private func updateCache(_ user: User) {
// Cache update logic
}
}
class ProfileViewController: UIViewController {
let profileManager = UserProfileManager()
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var emailLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
// ❌ ANOTHER RETAIN CYCLE: ViewController -> ProfileManager -> Closure -> ViewController
profileManager.addProfileUpdateHandler { [self] user in
nameLabel.text = user.name
emailLabel.text = user.email
refreshUI()
}
profileManager.observeProfileUpdates()
}
func refreshUI() {
// UI refresh logic
}
deinit {
print("ProfileViewController deallocated") // This will never print!
}
}What’s happening:
ProfileViewControllerholdsprofileManagerstronglyprofileManagerholds closures inupdateHandlersstrongly- Closures capture
self(the view controller) strongly - Nobody can be deallocated — classic retain cycle
The Professional Fix
class UserProfileManager {
var currentUser: User?
var profileUpdateHandlers: [(User) -> Void] = []
// ✅ SAFE: Using weak self to break retain cycle
func observeProfileUpdates() {
NetworkService.shared.onUserUpdate { [weak self] user in
guard let self = self else { return }
self.currentUser = user
self.notifyHandlers(user)
self.updateCache(user)
}
}
func addProfileUpdateHandler(_ handler: @escaping (User) -> Void) {
profileUpdateHandlers.append(handler)
}
private func notifyHandlers(_ user: User) {
profileUpdateHandlers.forEach { $0(user) }
}
private func updateCache(_ user: User) {
// Cache update logic
}
}
class ProfileViewController: UIViewController {
let profileManager = UserProfileManager()
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var emailLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
// ✅ SAFE: Using weak self and proper unwrapping
profileManager.addProfileUpdateHandler { [weak self] user in
guard let self = self else { return }
DispatchQueue.main.async {
self.nameLabel.text = user.name
self.emailLabel.text = user.email
self.refreshUI()
}
}
profileManager.observeProfileUpdates()
}
func refreshUI() {
// UI refresh logic
}
deinit {
print("ProfileViewController deallocated") // Now this will print!
}
}Weak vs Unowned: The Eternal Debate
class NetworkService {
weak var delegate: NetworkServiceDelegate?
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: someURL) { [weak self] data, response, error in
// Use 'weak self' when the captured object might be deallocated
guard let self = self else {
completion(.failure(NetworkError.serviceDeallocated))
return
}
if let error = error {
completion(.failure(error))
} else if let data = data {
self.delegate?.networkService(self, didReceiveData: data)
completion(.success(data))
}
}.resume()
}
}
class APIClient {
let networkService = NetworkService()
func loadUserData() {
// Use 'unowned self' when you're certain the captured object won't be deallocated
networkService.fetchData { [unowned self] result in
// Only use 'unowned' when you're 100% sure 'self' will outlive the closure
switch result {
case .success(let data):
self.processUserData(data)
case .failure(let error):
self.handleError(error)
}
}
}
private func processUserData(_ data: Data) {
// Processing logic
}
private func handleError(_ error: Error) {
// Error handling
}
}My rule: Use [weak self] by default. Only use [unowned self] when you're 100% certain the object will outlive the closure. When in doubt, use weak.
🔧 Capture Lists Explained
Capture lists let you control exactly what your closure grabs from the outside world:
Selective Capturing
class ImageProcessor {
var processingQueue: DispatchQueue
var imageCache: NSCache<NSString, UIImage>
var compressionQuality: CGFloat
init() {
self.processingQueue = DispatchQueue(label: "image-processing", qos: .userInitiated)
self.imageCache = NSCache<NSString, UIImage>()
self.compressionQuality = 0.8
}
func processImage(_ image: UIImage, completion: @escaping (UIImage?) -> Void) {
// ✅ Capture only what you need, not the entire 'self'
processingQueue.async { [imageCache, compressionQuality] in
// Process image using only captured values
let processedImage = self.applyFilters(to: image, quality: compressionQuality)
// Cache the result
let cacheKey = NSString(string: "processed_\(image.hash)")
imageCache.setObject(processedImage, forKey: cacheKey)
DispatchQueue.main.async {
completion(processedImage)
}
}
}
private func applyFilters(to image: UIImage, quality: CGFloat) -> UIImage {
// Image processing logic
return image
}
}Mixed Capture Types
class DataSyncManager {
weak var delegate: DataSyncDelegate?
var syncInterval: TimeInterval
var lastSyncDate: Date?
init(delegate: DataSyncDelegate?, syncInterval: TimeInterval = 300) {
self.delegate = delegate
self.syncInterval = syncInterval
}
func startPeriodicSync() {
// ✅ Mix of weak, strong, and value captures
Timer.scheduledTimer(withTimeInterval: syncInterval, repeats: true) { [weak self, syncInterval] timer in
guard let self = self else {
timer.invalidate()
return
}
// Capture delegate as weak to avoid retain cycle
guard let delegate = self.delegate else {
timer.invalidate()
return
}
self.performSync { [weak delegate, syncInterval] success in
guard let delegate = delegate else { return }
if success {
delegate.syncManagerDidCompleteSync(interval: syncInterval)
} else {
delegate.syncManagerDidFailSync()
}
}
}
}
private func performSync(completion: @escaping (Bool) -> Void) {
// Sync logic
DispatchQueue.global().async {
// Simulate network operation
Thread.sleep(forTimeInterval: 2)
completion(true)
}
}
}Conditional Captures
class AnalyticsManager {
var isEnabled: Bool
var userId: String?
var sessionId: String
init(isEnabled: Bool = true) {
self.isEnabled = isEnabled
self.sessionId = UUID().uuidString
}
func trackEvent(_ eventName: String, parameters: [String: Any] = [:]) {
guard isEnabled else { return }
// ✅ Conditional capturing based on state
let capturedUserId = userId // Capture current value, not reference
DispatchQueue.global(qos: .utility).async { [sessionId, capturedUserId] in
var eventData: [String: Any] = parameters
eventData["session_id"] = sessionId
if let userId = capturedUserId {
eventData["user_id"] = userId
}
// Send analytics event
self.sendAnalyticsEvent(eventName, data: eventData)
}
}
private func sendAnalyticsEvent(_ name: String, data: [String: Any]) {
// Network call to analytics service
}
}🚀 Advanced Closure Patterns
Now let’s look at some advanced patterns that show real mastery of closure semantics:
Pattern 1: Completion Handler Chaining
class APIService {
typealias APICompletion<T> = (Result<T, APIError>) -> Void
// ✅ Professional async chaining with proper error handling
func fetchUser(id: String, completion: @escaping APICompletion<User>) {
fetchUserData(id: id) { [weak self] result in
switch result {
case .success(let userData):
self?.fetchUserPreferences(id: id) { preferencesResult in
switch preferencesResult {
case .success(let preferences):
let user = User(data: userData, preferences: preferences)
completion(.success(user))
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
private func fetchUserData(id: String, completion: @escaping APICompletion<UserData>) {
// Network implementation
}
private func fetchUserPreferences(id: String, completion: @escaping APICompletion<UserPreferences>) {
// Network implementation
}
}Pattern 2: Custom Closure-Based Operators
extension Sequence {
// ✅ Custom functional operators using non-escaping closures
func asyncMap<T>(_ transform: @escaping (Element) -> T) -> [T] {
return map(transform) // Non-escaping because it executes immediately
}
func asyncForEach(_ action: @escaping (Element) -> Void, completion: @escaping () -> Void) {
let group = DispatchGroup()
forEach { element in
group.enter()
DispatchQueue.global().async { [action] in
action(element)
group.leave()
}
}
group.notify(queue: .main) {
completion()
}
}
}
// Usage
let numbers = [1, 2, 3, 4, 5]
numbers.asyncForEach({ number in
print("Processing \(number)")
}) {
print("All processing complete")
}Pattern 3: Framework-Style API Design
class RequestBuilder {
private var url: URL?
private var method: HTTPMethod = .GET
private var headers: [String: String] = [:]
private var parameters: [String: Any] = [:]
// ✅ Fluent API using non-escaping closures for configuration
func configure(_ configuration: (RequestBuilder) -> Void) -> RequestBuilder {
configuration(self)
return self
}
func url(_ url: URL) -> RequestBuilder {
self.url = url
return self
}
func method(_ method: HTTPMethod) -> RequestBuilder {
self.method = method
return self
}
func headers(_ headers: [String: String]) -> RequestBuilder {
self.headers.merge(headers) { _, new in new }
return self
}
// ✅ Execute with escaping completion handler
func execute<T: Decodable>(_ type: T.Type, completion: @escaping (Result<T, Error>) -> Void) {
guard let url = url else {
completion(.failure(RequestError.invalidURL))
return
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
URLSession.shared.dataTask(with: request) { data, response, error in
// Handle response and decode
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(RequestError.noData))
return
}
do {
let decoded = try JSONDecoder().decode(type, from: data)
completion(.success(decoded))
} catch {
completion(.failure(error))
}
}.resume()
}
}
// Usage demonstrates escaping vs non-escaping
RequestBuilder()
.url(URL(string: "https://api.example.com/users")!)
.method(.GET)
.configure { builder in // Non-escaping: executes immediately
builder.headers(["Authorization": "Bearer token"])
}
.execute(User.self) { result in // Escaping: executes after network call
switch result {
case .success(let user):
print("Got user: \(user)")
case .failure(let error):
print("Error: \(error)")
}
}📝 Interview Questions That Matter
Here are the real questions interviewers ask and how to answer them:
Question 1: “Why does this crash?”
class ViewController: UIViewController {
@IBOutlet weak var label: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.label.text = "Updated: \(Date())"
}
}
}Answer: “This creates a retain cycle. The timer holds a strong reference to the closure, the closure captures self strongly, and self (the view controller) indirectly holds the timer. When the view controller is dismissed, none of these objects can be deallocated. The fix is to use [weak self] in the closure and invalidate the timer in deinit or viewWillDisappear."
class ViewController: UIViewController {
@IBOutlet weak var label: UILabel!
private var updateTimer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.label.text = "Updated: \(Date())"
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
updateTimer?.invalidate()
updateTimer = nil
}
}Question 2: “What’s wrong with this async pattern?”
func loadData(completion: (Data?) -> Void) {
URLSession.shared.dataTask(with: someURL) { data, _, _ in
completion(data)
}.resume()
}Answer: “Several issues: 1) The completion handler should be marked @escaping because it's called after the function returns. 2) The completion is called on a background queue, which could cause UI updates on the wrong thread. 3) There's no error handling. Here's the fix:"
func loadData(completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: someURL) { data, response, error in
DispatchQueue.main.async {
if let error = error {
completion(.failure(error))
} else if let data = data {
completion(.success(data))
} else {
completion(.failure(NetworkError.noData))
}
}
}.resume()
}Question 3: “Explain the difference between these two functions”
func processSync(_ items: [Int], transform: (Int) -> Int) -> [Int] {
return items.map(transform)
}
func processAsync(_ items: [Int], transform: @escaping (Int) -> Int, completion: @escaping ([Int]) -> Void) {
DispatchQueue.global().async {
let result = items.map(transform)
DispatchQueue.main.async {
completion(result)
}
}
}Answer: “The first function uses a non-escaping closure that executes synchronously within the function call. The transform closure is guaranteed to complete before processSync returns, so no memory management is needed.
The second function uses escaping closures that execute after the function returns. Both transform and completion need @escaping because they're called asynchronously on different queues. This requires careful memory management to avoid retain cycles."
Question 4: “When would you use unowned vs weak?"
Professional Answer: “Use weak when the captured object might be deallocated before the closure executes — it returns an optional that you need to unwrap. Use unowned only when you're certain the captured object will outlive the closure execution — it provides direct access but crashes if the object is deallocated.
In practice, I almost always use weak because it's safer. The only time I use unowned is in very specific scenarios where I can guarantee the object lifetime, like a child object holding a closure that references its parent when the child can't outlive the parent."
⚡ Performance Considerations
Closures aren’t free — they cost memory and CPU time:
Before You Write Any Closure:
- Determine if it needs @escaping
- Will the closure execute after the function returns? →
@escaping - Does it execute synchronously within the function? → Non-escaping (default)
2\. Plan your capture strategy
- Does the closure need to outlive the object? →
[weak self] - Are you certain the object will outlive the closure? →
[unowned self](rare) - Do you only need specific properties? → Capture them directly
3\. Consider threading
- Where will the closure execute?
- Do UI updates need to be dispatched to main queue?
- Are there thread safety concerns?
After Implementation:
4\. Test memory management
- Use Instruments to check for retain cycles
- Verify objects deallocate when expected
- Test with realistic usage patterns
5\. Review error handling
- Do all code paths call completion handlers?
- Are errors properly propagated?
- Is threading handled correctly?
Red Flags to Avoid:
- ❌ Using
@escapingeverywhere "just to be safe" - ❌ Strong self captures in long-lived closures
- ❌ Forgetting to dispatch UI updates to main queue
- ❌ Not handling the case where
weak selfbecomesnil - ❌ Creating new closures in tight loops
- ❌ Mixing escaping and non-escaping patterns inconsistently
Closures used to confuse me too. The first time someone asked about escaping vs non-escaping in an interview, I gave some confused answer about “scope” and hoped for the best.
But once you understand it’s really about memory management and timing, everything clicks. It’s not mystical Swift magic — it’s just “when does this run and who keeps track of what?”
Start by auditing your existing code for retain cycles. Practice these patterns until they feel natural. Most importantly, understand what you’re doing instead of just copying solutions.
Your future self will thank you when you’re writing solid async code instead of debugging mysterious crashes at 2 AM.
📺 Want to see these patterns in action with live debugging? Check out the complete video tutorial on Swift Pal: _https://youtube.com/@swift-pal_
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read