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.
