DispatchQueue.main.asyncAfter(deadline: .now() + 4) in callback-style code, or try await Task.sleep(for: .seconds(4)) inside async code. Avoid sleep(4) and Thread.sleep(forTimeInterval:) on the main thread — they block the thread that draws the UI, so the app freezes for the whole delay.Why this happens
A delay is not a language feature in Swift; it is something you get from a scheduling API. The naive choice is sleep(4) from UNIX or Thread.sleep(forTimeInterval: 4) from Foundation. Both do exactly one thing: they stop the calling thread dead for the requested time. Nothing else runs on that thread until the call returns.
That is fine on a background queue. On the main thread it is not, because the main thread is the one draining the run loop, laying out views, and delivering touch events. Block the main thread for four seconds and the interface stops responding for four seconds — no scrolling, no animations, no taps. Block it long enough and the system watchdog can terminate the app.
Most of the time you do not actually want to stop a thread. You want to run a piece of code later, while everything else keeps working. That is a scheduling problem, and Swift has three good answers for it depending on whether your code is callback-based, async, or SwiftUI.
How to fix it
1. Callback code: DispatchQueue.main.asyncAfter
If you are in a closure-based context — a button action, a delegate method, a completion handler — schedule the work instead of sleeping.
import Foundation
func showBannerLater() {
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
self?.showBanner()
}
}
.now() + 4 builds a DispatchTime four seconds in the future. The block runs on the main queue after that deadline, and the main thread stays free in the meantime.
Two details worth getting right:
- Capture
[weak self]when the closure touches a view controller or a long-lived object, otherwise you keep it alive for the duration of the delay. asyncAfterdoes not cancel. If the user leaves the screen before the deadline, the block still fires. Guard against that with a flag or by checking that the object is still in the expected state.
If you need to run the delayed work off the main thread, use a background queue instead of DispatchQueue.main:
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 4) {
processFile()
}
2. Async code: Task.sleep
In an async function, use Task.sleep. It suspends the current task rather than blocking the thread, so the thread is released to do other work.
func refreshAfterPause() async throws {
try await Task.sleep(for: .seconds(4)) // iOS 16, macOS 13 and later
await refresh()
}
On iOS 15 and earlier, the Duration-based overload is unavailable, so pass nanoseconds:
func refreshAfterPauseLegacy() async throws {
try await Task.sleep(nanoseconds: 4 * 1_000_000_000)
await refresh()
}
Task.sleep throws CancellationError if the surrounding task is cancelled while it is waiting. That is a feature: it is how a delayed operation gets torn down when the user navigates away. Handle it explicitly when the difference matters:
func refreshAfterPause() async {
do {
try await Task.sleep(for: .seconds(4))
} catch {
return // cancelled — do not continue
}
await refresh()
}
If the enclosing function is main-actor isolated (a @MainActor type, or a View body’s task), execution resumes on the main actor after the sleep, so it is safe to touch UI state right after the await.
3. SwiftUI: the .task modifier
In a view, attach the delay to the view’s lifetime with .task. SwiftUI starts the task when the view appears and cancels it when the view disappears.
import SwiftUI
struct DelayedMessageView: View {
@State private var message = "Loading…"
var body: some View {
Text(message)
.task {
try? await Task.sleep(for: .seconds(4))
guard !Task.isCancelled else { return }
message = "Ready"
}
}
}
The guard !Task.isCancelled line matters. try? swallows the CancellationError, so without the check you would set state on a view that is already gone.
If the delay should restart whenever some value changes — a search field, a selected ID — use .task(id:) instead. SwiftUI cancels the previous task and starts a new one each time the id changes, which is the cleanest way to debounce work:
.task(id: query) {
try? await Task.sleep(for: .seconds(1))
guard !Task.isCancelled else { return }
await search(query)
}
For a delay triggered by a button tap rather than by appearance, wrap it in a Task yourself. That task is not tied to the view’s lifetime, so cancel it in onDisappear if the work must not outlive the screen.
4. Repeated work: Timer
When the delay repeats, a timer is usually a better fit than chaining asyncAfter calls.
let timer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in
pollStatus()
}
Timers need a run loop, so schedule them from the main thread for UI work. They are also not precise to the millisecond — they fire on the next run loop pass after the interval elapses, and they pause while the app is in the background.
5. When blocking is actually correct
sleep and Thread.sleep(forTimeInterval:) are legitimate on a background thread, in a command-line tool, or in a test where you deliberately want to stop execution. The rule is about the thread, not the API: never block the main thread.
DispatchQueue.global(qos: .background).async {
Thread.sleep(forTimeInterval: 4) // acceptable here
cleanup()
}
How to prevent it
- Treat
sleepandThread.sleepas banned on the main thread; if you see one in a view controller or a SwiftUI action, replace it withasyncAfterorTask.sleep. - Prefer
Task.sleepoverasyncAfterin any code that is alreadyasync— it composes with cancellation and with structured concurrency. - Always handle cancellation when the delayed work touches UI: check
Task.isCancelledafter atry?sleep. - Use
[weak self]inasyncAfterclosures that capture view controllers. - Use
.task(id:)for debouncing instead of hand-rolled timer invalidation. - Do not assume a delay is exact. Both
asyncAfterandTask.sleepguarantee “not before”, not “exactly at”.
FAQ
Does Task.sleep block the thread?
No. It suspends the current task and hands the thread back to the scheduler, so other work continues. Thread.sleep(forTimeInterval:) is the blocking equivalent and should stay off the main thread.
How do I delay inside a SwiftUI button action?
Wrap the wait in a Task, since the action closure is not async: Task { try? await Task.sleep(for: .seconds(4)); state = .done }. If the delay must be cancelled when the screen closes, keep a reference to that task and cancel it in onDisappear.
Why does my delayed code still run after I leave the screen?
Because DispatchQueue.main.asyncAfter has no cancellation. The block is queued and will fire regardless of what happened to the view. Switch to .task or Task.sleep inside a task you cancel, or guard the closure with a check that the screen is still active.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
