How to Delay Code Execution in Swift

How to Delay Code Execution in Swift

Written by

in

Quick answer: To delay code execution without freezing the main thread, use Task.sleep(for: .seconds(2)) inside an asynchronous context. For non-async code, use DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { ... } or schedule a Timer attached to the .common run loop mode if you need cancellation support.

Executing code after an intentional pause is a fundamental requirement in iOS and macOS development. Whether you need to debounce search queries, stagger UI animations, or retry a failed network request, scheduling delayed work properly ensures your user interface stays responsive.

Historically, Swift developers relied on legacy C-style Grand Central Dispatch functions like dispatch_after and dispatch_time. Modern Swift provides safer, object-oriented abstractions through Swift Concurrency (Task.sleep), modern GCD (DispatchQueue.asyncAfter), and closure-based Timer instances.

Why this happens

When developers first attempt to pause execution, they often call Thread.sleep(forTimeInterval:) or the C function sleep(). While this halts execution, calling it on the main thread completely freezes the run loop. Touches cease to register, animations stutter, and the system watchdog will eventually terminate your app for becoming unresponsive.

In legacy codebases written before Swift 3, developers scheduled non-blocking delays using dispatch_after(dispatch_time(DISPATCH_TIME_NOW, ...), dispatch_get_main_queue(), ...). During the major Swift API design overhaul, Apple deprecated these procedural C-style macros in favor of the Swift-native DispatchQueue syntax. Passing legacy constants like DISPATCH_TIME_NOW to modern Swift APIs results in compiler errors such as Cannot convert 'DispatchTime.now' to expected value 'dispatch_time_t'.

To schedule future work safely, your code must instruct the operating system or run loop to resume or invoke a closure at a later timestamp without occupying or blocking the underlying thread.

How to fix it

1. Modern Swift Concurrency (Task.sleep)

In Swift 5.7 and later, Swift Concurrency provides the cleanest, safest way to suspend execution. Unlike blocking threads, Task.sleep suspends the asynchronous task, allowing the underlying thread to continue handling user interactions, layout passes, and other work.

import SwiftUI

func performDelayedAction() async {
    print("Starting operation...")

    // Suspends execution for 2 seconds without blocking the thread
    try? await Task.sleep(for: .seconds(2))

    print("Executed 2 seconds later.")
}

If you need to trigger this delayed logic from a synchronous method (such as a standard button tap action or viewDidLoad), wrap the call in a detached or unstructured Task:

Task {
    try? await Task.sleep(for: .seconds(1.5))
    await MainActor.run {
        // Update UI state here
    }
}

Task.sleep automatically supports cancellation. If the parent task is cancelled (for instance, when a user leaves a screen), Task.sleep throws a CancellationError, preventing unnecessary work from running.

2. Grand Central Dispatch (DispatchQueue.asyncAfter)

If your project does not use Swift Concurrency or you are working within existing callback-based architectures, DispatchQueue.asyncAfter is the direct, standard replacement for legacy dispatch_after.

Use .main if you plan to update UI components, or a global background queue if you are scheduling non-visual computations.

import UIKit

class ProfileViewController: UIViewController {

    func showBannerWithDelay() {
        let delayInSeconds = 2.0

        DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { [weak self] in
            // Guard self to avoid executing on a deallocated view controller
            guard let self = self else { return }

            self.displayNotificationBanner()
        }
    }

    private func displayNotificationBanner() {
        // UI presentation logic
    }
}

Always capture [weak self] inside the closure if the work belongs to a view controller or class that may be dismissed before the timer fires. Failing to capture weakly can cause memory leaks or retain cycles until the block finishes executing.

3. Cancellable Timer with .common RunLoop Mode

While DispatchQueue.asyncAfter is simple, standard GCD dispatch blocks cannot be cancelled once submitted without cumbersome work item tracking. If you need the ability to abort the delayed action before it fires, use a closure-based Timer.

By default, Timer.scheduledTimer registers the timer on RunLoop.Mode.default. If a user scrolls a UIScrollView, UITableView, or UICollectionView, the run loop switches to tracking mode, pausing your timer until scrolling ends. To keep the timer ticking during scrolling interactions, add it to .common modes:

import UIKit

class SearchViewController: UIViewController {
    private var delayTimer: Timer?

    func scheduleDelayedSearch(for query: String) {
        // Cancel any existing pending operation
        delayTimer?.invalidate()

        // Create an unrepeating timer
        let timer = Timer(timeInterval: 0.5, repeats: false) { [weak self] _ in
            self?.performSearch(query: query)
        }

        // Add to the common mode so user scroll gestures don't freeze the timer
        RunLoop.current.add(timer, forMode: .common)
        self.delayTimer = timer
    }

    func cancelPendingWork() {
        delayTimer?.invalidate()
        delayTimer = nil
    }

    private func performSearch(query: String) {
        print("Searching for: \(query)")
    }
}

4. Delaying Actions in SwiftUI Views

In SwiftUI, you can coordinate delayed actions directly within the view lifecycle using the .task modifier. SwiftUI automatically manages task cancellation when the view disappears from the screen.

import SwiftUI

struct WelcomeAlertView: View {
    @State private var showBanner = false

    var body: some View {
        VStack {
            if showBanner {
                Text("Welcome back!")
                    .padding()
                    .background(Color.blue.opacity(0.1))
                    .cornerRadius(8)
            }
            Text("Main Content")
        }
        .task {
            // Automatically cancelled if WelcomeAlertView disappears before 3 seconds
            try? await Task.sleep(for: .seconds(3))
            withAnimation {
                showBanner = true
            }
        }
    }
}

How to prevent it

  • Never call Thread.sleep on the main thread: It halts the run loop, blocks event delivery, and triggers watchdog crashes.
  • Capture [weak self] in closures: Prevent retain cycles and ensure delayed blocks do not hold discarded view controllers in memory.
  • Use .common run loop mode for Timer: Avoid timers that freeze unexpectedly while users scroll through table views or collection views.
  • Prefer Swift Concurrency for automatic cancellation: Use structured concurrency (.task in SwiftUI or child tasks) so delays are torn down instantly when views disappear.

FAQ

What is the difference between Thread.sleep and Task.sleep?

Thread.sleep actively blocks the current underlying operating system thread, preventing it from executing any other work. In contrast, Task.sleep yields the cooperative thread pool back to the Swift runtime, suspending only the task itself while freeing the thread to handle other tasks and UI events.

Can I cancel a DispatchQueue.main.asyncAfter call?

Direct closures passed to asyncAfter cannot be cancelled directly. To make GCD calls cancellable, wrap your logic inside a DispatchWorkItem, store a reference to it, and call .cancel() on the item before the deadline expires.

Why does my Timer stop running when a user touches the screen?

A Timer scheduled on the default run loop mode is temporarily suspended when tracking gestures or scrolling UI elements. To fix this, register the timer using RunLoop.current.add(timer, forMode: .common).

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.