Articles, podcasts and news about Swift development, by John Sundell.

Delaying an asynchronous Swift Task

Published on 13 Dec 2021
Discover page available: Concurrency

Most often, we want our various asynchronous tasks to start as soon as possible after they’ve been created, but sometimes we might want to add a slight delay to their execution — perhaps in order to give another task time to complete first, or to add some form of “debouncing” behavior.

Although there’s no direct, built-in way to run a Swift Task with a certain amount of delay, we can achieve that behavior by telling the task to sleep for a given number of nanoseconds before we actually start performing its operation:

Task {
    // Delay the task by 1 second:
    try await Task.sleep(nanoseconds: 1_000_000_000)
    
    // Perform our operation
    ...
}

Calling Task.sleep is very different from using things like the sleep system function, as the Task version is completely non-blocking in relation to other code.

The reason that the above call to Task.sleep is marked with the try keyword is because that call will throw an error in case the task was cancelled during its sleeping time. So, for example, if we wanted to make a view controller only show a loading spinner if an async operation took more than 150 milliseconds to complete, then we could implement something like this:

class VideoViewController: UIViewController {
    ...
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        let loadingSpinnerTask = Task {
            try await Task.sleep(nanoseconds: 150_000_000)
            showLoadingSpinner()
        }

        Task {
            await prepareVideo()
            loadingSpinnerTask.cancel()
            hideLoadingSpinner()
        }
    }
    
    ...
}

Please note that the above is not meant to be a complete example on how to use a Task to load a view controller’s content. For example, we probably want to check whether an existing loading task is already in progress before starting a new one. To learn more, check out “What role do Tasks play within Swift’s concurrency system?”.

Now, if we’re going to use a lot of delayed tasks within a given code base, then it might be worth defining a simple abstraction that would let us create such delayed tasks more easily — for example by enabling us to use a more standard TimeInterval value to define second-based delays, rather than having to use nanoseconds:

extension Task where Failure == Error {
    static func delayed(
        byTimeInterval delayInterval: TimeInterval,
        priority: TaskPriority? = nil,
        operation: @escaping @Sendable () async throws -> Success
    ) -> Task {
        Task(priority: priority) {
            let delay = UInt64(delayInterval * 1_000_000_000)
            try await Task<Never, Never>.sleep(nanoseconds: delay)
            return try await operation()
        }
    }
}

The reason we have to explicitly mark our sleep task as Task<Never, Never> is because that method is only available on that exact Task specialization, and within the scope of our extension, the symbol Task refers to the current specialization that our extension is being used with.

With the above extension in place, we can now simply call Task.delayed whenever we want to create a delayed task. The only downside of that approach is that we now have to manually capture self within those task closures:

class VideoViewController: UIViewController {
    ...

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        let loadingSpinnerTask = Task.delayed(byTimeInterval: 0.15) {
    self.showLoadingSpinner()
}

        Task {
            await prepareVideo()
            loadingSpinnerTask.cancel()
            hideLoadingSpinner()
        }
    }
    
    ...
}

There is one way around that minor problem, though — and that’s to use the “semi-public” _implicitSelfCapture attribute — which is what the Swift standard library uses to make all built-in Task closures automatically capture self references:

extension Task where Failure == Error {
    static func delayed(
        byTimeInterval delayInterval: TimeInterval,
        priority: TaskPriority? = nil,
        @_implicitSelfCapture operation: @escaping @Sendable () async throws -> Success
    ) -> Task {
        ...
    }
}

However, since the above attribute is not yet an official part of Swift’s public API (given that its name is prefixed with an underscore), I wouldn’t really recommend using it in production code — unless you’re willing to accept the risk that any code using it might break at any point.

I hope you enjoyed this quick look at a few different ways to model delayed operations using Swift’s new built-in Task API. Of course, we also have the option to use older tools to implement such delays — like Grand Central Dispatch, timers, or even the Objective-C runtime (at least in some cases). But, when writing async code using Swift’s new concurrency system, being able to directly use the Task type to achieve that kind of delaying behavior could be really convenient.

Like always, feel free to send me an email if you have any questions, comments, or feedback.

Thanks for reading!