dispatch_after is replaced by DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { ... }, where the number is seconds as a Double. In async code, use try await Task.sleep(for: .seconds(2)) on iOS 16 / macOS 13 and later, or Task.sleep(nanoseconds:) for older targets. To cancel delayed work, keep a DispatchWorkItem or a Task and call cancel().Why this happens
In Swift 2, Grand Central Dispatch was exposed as a set of C functions: dispatch_time, dispatch_after, dispatch_get_main_queue, and so on. Swift 3 replaced those global functions with a Swift-style API built around the DispatchQueue, DispatchTime and DispatchTimeInterval types. The old C calls are no longer available in Swift, so code like this stops compiling:
// Swift 2 – does not compile in Swift 3 and later
var dispatchTime: dispatch_time_t = dispatch_time(DISPATCH_TIME_NOW, Int64(0.1 * Double(NSEC_PER_SEC)))
dispatch_after(dispatchTime, dispatch_get_main_queue(), {
// work
})
The direct replacement is asyncAfter(deadline:execute:) on a DispatchQueue. You also no longer need to multiply by NSEC_PER_SEC by hand.
Swift concurrency added a second option: suspending an async function with Task.sleep. Which one you pick depends on whether the surrounding code is callback-based or async/await.
How to fix it
1. Use DispatchQueue.main.asyncAfter
This is the one-line replacement for dispatch_after, and it works in Swift 3, 4, 5 and 6:
import Foundation
// Runs the closure on the main queue about 0.1 seconds from now
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// your code here
}
The + 0.1 part can look odd. It works because DispatchTime has a + operator that takes a Double and treats it as seconds:
public func +(time: DispatchTime, seconds: Double) -> DispatchTime
If you want to run the work off the main thread, call asyncAfter on another queue, for example DispatchQueue.global(qos: .utility). Move any UI updates back to the main queue.
2. Use DispatchTimeInterval for exact units
Fractions of a second as a Double are easy to misread. There is a second + overload that takes a DispatchTimeInterval, so you can state the unit directly:
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
// 0.5 seconds
}
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
// 2 seconds
}
DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(1_000_000)) {
// 1 second
}
DispatchQueue.main.asyncAfter(deadline: .now() + .nanoseconds(1_500_000_000)) {
// 1.5 seconds
}
This also fixes a common follow-up error. A literal like .now() + 5 compiles because the literal becomes a Double. A variable of type Int does not convert:
let delay = 5
// error: Binary operator '+' cannot be applied to operands of type 'DispatchTime' and 'Int'
// DispatchQueue.main.asyncAfter(deadline: .now() + delay) { }
// Either of these compiles:
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(delay)) { }
DispatchQueue.main.asyncAfter(deadline: .now() + Double(delay)) { }
3. Make the delayed work cancelable with DispatchWorkItem
A plain closure passed to asyncAfter can’t be canceled. If the work might become irrelevant, for example because the screen was dismissed, wrap it in a DispatchWorkItem and keep a reference to it:
import UIKit
final class ViewController: UIViewController {
private var pendingItem: DispatchWorkItem?
override func viewDidLoad() {
super.viewDidLoad()
let item = DispatchWorkItem { [weak self] in
self?.doSomething()
self?.pendingItem = nil
}
pendingItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: item)
}
deinit {
pendingItem?.cancel()
}
private func doSomething() {
// delayed work
}
}
Three details matter here:
[weak self]is required. The view controller holds the work item, so a strong capture ofselfcreates a retain cycle anddeinitnever runs.- Cancel isn’t preemptive.
cancel()stops the item from starting if it hasn’t run yet. If the block is already running, it finishes unless you checkisCancelledinside it yourself. - Debouncing is a natural fit. Cancel the previous item and schedule a new one each time an event fires. You get “run only after the user stops typing” behavior without a timer.
4. In async code, use Task.sleep
If you’re already inside an async context, don’t go back to GCD. Suspend the task instead:
// iOS 16+, macOS 13+
try await Task.sleep(for: .seconds(2))
try await Task.sleep(for: .milliseconds(200))
// iOS 13+, macOS 10.15+
try await Task.sleep(nanoseconds: 2_000_000_000)
Calling sleep() on a thread used to be an anti-pattern because it blocks that thread. Task.sleep is different. It suspends the current task and frees the thread for other work while it waits.
To cancel, store the Task and call cancel(). A canceled Task.sleep throws CancellationError, so the code after it never runs:
import UIKit
final class ViewController: UIViewController {
private var delayTask: Task<Void, Error>?
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
delayTask = Task {
try await Task.sleep(for: .seconds(5))
await doSomething()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
delayTask?.cancel()
}
private func doSomething() async {
// delayed work
}
}
Unlike a DispatchWorkItem, this cancellation works while the task is waiting. The sleep ends early with an error instead of the work running late.
5. In SwiftUI, use the .task modifier
SwiftUI’s .task modifier starts async work when the view appears and cancels it automatically after the view disappears. You don’t need to store or cancel anything yourself:
import SwiftUI
struct SavedBanner: View {
@State private var isVisible = true
var body: some View {
Text("Saved")
.opacity(isVisible ? 1 : 0)
.task {
do {
try await Task.sleep(for: .seconds(2))
} catch {
return // view went away; skip the update
}
withAnimation { isVisible = false }
}
}
}
Avoid writing try? await Task.sleep(...) here. The try? swallows the CancellationError, so the code after it still runs even though the task was canceled. Catch the error and return, or check Task.isCancelled before doing the follow-up work.
If the delay should restart whenever a value changes, use .task(id:). SwiftUI cancels the previous task and starts a new one each time the id changes.
How to prevent it
- Use
DispatchQueue.main.asyncAfterin callback-based code andTask.sleepinasynccode. Avoid mixing them in the same flow. - Prefer explicit units (
.milliseconds(300),.seconds(2)) over bareDoubleliterals so the delay is obvious when someone reads the code. - Any delayed work that touches a view controller or view model should be cancelable, and it should capture
selfweakly when using GCD. - In SwiftUI, put delays in
.taskinstead ofonAppearplusasyncAfter, so cancellation comes for free. - Don’t use a delay to “wait for” something like layout or a network response. Use the real completion signal instead.
FAQ
Is asyncAfter exact?
No. It guarantees the work won’t start before the deadline. The actual start can be slightly later depending on what the queue and system are doing. For UI delays this is fine, but don’t use it for precise timing.
What’s the difference between deadline and wallDeadline?
deadline: takes a DispatchTime, which is based on the system’s uptime clock. wallDeadline: takes a DispatchWallTime, which follows the wall clock. For ordinary “do this in N seconds” delays, deadline: .now() + N is the usual choice.
Can I still use Task.sleep if my app supports iOS 15?
Yes, but Task.sleep(for:) with a Duration requires iOS 16 / macOS 13. On older deployment targets, use Task.sleep(nanoseconds:), or wrap the newer call in an if #available(iOS 16, *) check.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.