All articles
iOS8 min read

The iOS Interview Question That Exposes Junior Developers Every Time

The escaping vs non-escaping patterns that make your async code actually work

K
Karan Pal
Author

Interview Helper

The escaping vs non-escaping patterns that make your async code actually work

Image Generated by AI
Image Generated by AI

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:

  1. ProfileViewController holds profileManager strongly
  2. profileManager holds closures in updateHandlers strongly
  3. Closures capture self (the view controller) strongly
  4. 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:

  1. Determine if it needs @escaping

2\. Plan your capture strategy

3\. Consider threading

After Implementation:

4\. Test memory management

5\. Review error handling

Red Flags to Avoid:

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! 🚀

● The newsletter

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