Swift

Swift Closures Explained: @escaping, Non-Escaping, and Trailing Closures with Code

By Seren  |  06 May, 2026  |  Leave a comment


Closures are one of Swift’s most essential features. Most articles tell you how to write them. This one tells you why Swift distinguishes between escaping and non-escaping — and why that distinction matters for performance and memory safety.

I learned this the hard way when a retain cycle in an escaping closure caused a memory leak that grew by 50MB every time a user opened the settings screen. The closure captured self, the closure was stored as a property, and deinit never ran. Understanding escaping vs non-escaping isn’t just an interview question — it’s the difference between a stable app and a memory bomb.

Code 1: Closure Basics

A closure is a self-contained block of code that can capture and store references to variables and constants from the context in which it’s defined. Swift has three forms:

// 1. Global function — named, doesn't capture any values
func greet(name: String) -> String {
    return "Hello, \(name)"
}

// 2. Nested function — named, captures values from enclosing function
func makeGreeter() -> (String) -> String {
    let greeting = "Hey"  // captured from enclosing scope
    func greeter(name: String) -> String {
        return "\(greeting), \(name)"  // uses captured variable
    }
    return greeter
}
let greet = makeGreeter()
print(greet("Alice"))  // "Hey, Alice"

// 3. Closure expression — unnamed, most common in day-to-day code
let add: (Int, Int) -> Int = { a, b in
    return a + b
}
print(add(3, 5))  // 8

// Shortest form — shorthand argument names
let double = { $0 * 2 }
print(double(7))  // 14

Code 2: Non-Escaping Closures (The Default)

Definition: A closure that executes before the function returns. It does not “escape” the function’s scope. In Swift, closure parameters are non-escaping by default — no keyword needed.

// Non-escaping: the closure runs synchronously, before process() returns
func process<T>(_ items: [T], transform: (T) -> String) -> [String] {
    var results: [String] = []
    for item in items {
        results.append(transform(item))  // closure executes here
    }
    return results  // function returns AFTER closure runs
}

let names = ["alice", "bob", "charlie"]
let capitalized = process(names) { $0.prefix(1).uppercased() + $0.dropFirst() }
print(capitalized)  // ["Alice", "Bob", "Charlie"]

// Why non-escaping is faster:
// 1. The compiler allocates the closure on the STACK (fast, automatic cleanup)
// 2. No retain cycle risk — the closure dies when the function returns
// 3. No need for [weak self] — guaranteed to finish before self could be deallocated

// You CAN still capture variables in a non-escaping closure:
var counter = 0
let increment = { counter += 1 }  // captures counter by reference
increment()
increment()
print(counter)  // 2 — captured variable is modified

Code 3: @escaping Closures (The Dangerous One)

Definition: A closure that executes after the function returns. It “escapes” the function’s scope. Swift forces you to mark such closures with @escaping.

// @escaping: the closure outlives the function
func fetchData(url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        // This closure runs AFTER fetchData() returns!
        if let error = error {
            completion(.failure(error))
            return
        }
        completion(.success(data ?? Data()))
    }.resume()
    // fetchData() returns here, but the completion closure is still alive
}

// Why @escaping requires [weak self] in class methods:
class APIClient {
    var isLoading = false

    func loadUsers() {
        isLoading = true
        fetchData(url: usersURL) { [weak self] result in
            // Without [weak self]: self -> fetchData closure -> self = RETAIN CYCLE
            // With [weak self]: self can be deallocated even if closure is still alive
            self?.isLoading = false
            switch result {
            case .success(let data): print("Got \(data.count) bytes")
            case .failure(let error): print("Failed: \(error)")
            }
        }
    }
}

// Escaping closures must be allocated on the HEAP:
// This is slower than stack allocation, but necessary because the closure
// outlives the function and might be called from a different thread.

// Real-world: storing a closure for later use
class NetworkManager {
    private var pendingCallbacks: [String: (Data) -> Void] = [:]

    func register(key: String, callback: @escaping (Data) -> Void) {
        pendingCallbacks[key] = callback  // stored — must be @escaping
    }

    func resolve(key: String, data: Data) {
        pendingCallbacks[key]?(data)  // called later, possibly on a different thread
        pendingCallbacks.removeValue(forKey: key)
    }
}

Code 4: Trailing Closures

Definition: When a closure is the last argument of a function, you can omit the argument label and write the closure outside the parentheses.

// WITHOUT trailing closure — the traditional way
let numbers = [1, 2, 3, 4, 5]
let doubled = numbers.map({ (n: Int) -> Int in return n * 2 })

// WITH trailing closure — cleaner, same result
let doubled = numbers.map { n in n * 2 }

// Even shorter with shorthand arguments
let doubled = numbers.map { $0 * 2 }

// Real-world: UIView.animate with trailing closure
UIView.animate(withDuration: 0.3) {
    self.view.alpha = 0  // trailing closure syntax
    self.view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
}

// Real-world: DispatchQueue with trailing closure
DispatchQueue.main.async {
    self.tableView.reloadData()
}

// Real-world: Result with trailing closure
fetchUser { result in
    switch result {
    case .success(let user): self.updateUI(with: user)
    case .failure(let error): self.showError(error)
    }
}

Code 5: Multiple Trailing Closures (Swift 5.3+)

When a function has multiple closure parameters, Swift 5.3+ lets you write each one as a separate trailing closure block. The first closure replaces the normal parenthesized call, and subsequent closures follow as labeled blocks:

// Function with multiple closure parameters
func loadData(
    onSuccess: @escaping ([String]) -> Void,
    onFailure: @escaping (Error) -> Void,
    onComplete: @escaping () -> Void
) {
    // ... async work ...
}

// OLD way (before Swift 5.3) — ugly nesting
loadData(
    onSuccess: { items in print("Got \(items.count) items") },
    onFailure: { error in print("Failed: \(error)") },
    onComplete: { print("Done") }
)

// NEW way (Swift 5.3+) — each closure is a separate labeled block
loadData { items in
    print("Got \(items.count) items")
} onFailure: { error in
    print("Failed: \(error)")
} onComplete: {
    print("Done")
}

// Real-world: async/await compatible network call with progress
func uploadImage(
    data: Data,
    onProgress: @escaping (Double) -> Void,
    onCompletion: @escaping (Result<URL, Error>) -> Void
) {
    let task = URLSession.shared.uploadTask(with: request, from: data) { response, error in
        if let error = error {
            onCompletion(.failure(error))
        } else {
            onCompletion(.success(responseURL))
        }
    }
    task.resume()
}

// Calling it with multiple trailing closures — each block has its own label
uploadImage(data: imageData) { progress in
    progressView.progress = Float(progress)
    print("Upload \(Int(progress * 100))%")
} onCompletion: { result in
    switch result {
    case .success(let url): print("Uploaded to \(url)")
    case .failure(let error): print("Upload failed: \(error)")
    }
}

Complete Example: All Three Types in One Class

class ProfileManager {
    private var cachedProfile: UserProfile?

    // Non-escaping: synchronous filtering, runs before return
    func filterFriends(_ profile: UserProfile, check: (Friend) -> Bool) -> [Friend] {
        return profile.friends.filter(check)
    }

    // @escaping: async network call, closure runs after return
    func loadProfile(
        completion: @escaping (Result<UserProfile, Error>) -> Void
    ) {
        URLSession.shared.dataTask(with: profileURL) { [weak self] data, _, error in
            guard let data = data, error == nil else {
                completion(.failure(error ?? NSError()))
                return
            }
            let profile = try? JSONDecoder().decode(UserProfile.self, from: data)
            if let profile = profile {
                self?.cachedProfile = profile
                completion(.success(profile))
            } else {
                completion(.failure(NSError()))
            }
        }.resume()
    }

    // Multiple trailing closures (Swift 5.3+)
    func refreshProfile(
        onProgress: @escaping (String) -> Void,
        onComplete: @escaping (UserProfile?) -> Void
    ) {
        onProgress("Starting refresh...")
        loadProfile { result in
            switch result {
            case .success(let profile):
                onProgress("Profile loaded")
                onComplete(profile)
            case .failure:
                onProgress("Refresh failed")
                onComplete(nil)
            }
        }
    }
}

// Usage — all three patterns in one screen
let manager = ProfileManager()

// 1. Non-escaping — synchronous
let closeFriends = manager.filterFriends(myProfile) { $0.isCloseFriend }

// 2. @escaping with trailing closure — async
var profile: UserProfile?
manager.loadProfile { result in
    if case .success(let p) = result { profile = p }
}

// 3. Multiple trailing closures — async with progress
manager.refreshProfile { progressMessage in
    statusLabel.text = progressMessage
} onComplete: { refreshedProfile in
    if let p = refreshedProfile {
        self.updateUI(with: p)
    }
}

Interview Follow-Ups

Q: Are all async closures escaping? A: Yes. Any closure that executes after the function returns must be marked @escaping. The compiler enforces this.

Q: Does @escaping impact performance? A: Yes. Escaping closures must be allocated on the heap, while non-escaping closures can use stack allocation — which is faster. Swift defaults to non-escaping for this reason.

Q: [weak self] vs [unowned self]? Use [weak self] when the closure lifetime is uncertain (90% of async cases). Use [unowned self] only when self is guaranteed to outlive the closure (animation callbacks). When in doubt, use [weak self].

Summary

Non-escaping (default) — faster, safer, stack-allocated. Use when the closure runs synchronously. Escaping (@escaping) — required for async work. Always watch for retain cycles. Trailing — syntactic sugar for cleaner code, especially with async APIs. Multiple trailing closures (Swift 5.3+) — separate labeled blocks for functions with multiple closure parameters.

Understanding these three types isn’t just about passing interviews — it’s about writing Swift code that’s performant, safe, and idiomatic. The language gives you non-escaping by default for a reason: performance matters, and the compiler can guarantee safety when it controls the closure’s lifecycle.

Now go write some closures — and don’t forget [weak self] when you need it.

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *