Swift
async await
background tasks
concurrency
@MainActor

What is the best solution for background task using Swift async, await, MainActor

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

With Swift concurrency, the best pattern is usually to keep expensive work off the main actor and hop to MainActor only for UI-facing updates. The biggest source of confusion is that "background task" can mean two very different things: ordinary work that should not block the UI while the app is active, or true system-level background execution while the app is not in the foreground.

@MainActor Is for UI State, Not for All Async Code

The main actor protects UI-related state and other main-thread-only state. It is not a label you should put on an entire workflow just because the workflow eventually updates the screen.

A good split looks like this:

swift
1import Foundation
2
3struct User: Decodable {
4    let name: String
5}
6
7func loadUser() async throws -> User {
8    let url = URL(string: "https://example.com/user.json")!
9    let (data, _) = try await URLSession.shared.data(from: url)
10    return try JSONDecoder().decode(User.self, from: data)
11}

Then use the main actor only where UI state changes happen:

swift
1import Foundation
2
3@MainActor
4final class UserViewModel: ObservableObject {
5    @Published var username = ""
6    @Published var isLoading = false
7
8    func refresh() {
9        Task {
10            isLoading = true
11            defer { isLoading = false }
12
13            do {
14                let user = try await loadUser()
15                username = user.name
16            } catch {
17                username = "Failed to load"
18            }
19        }
20    }
21}

Even though the task starts from a main-actor context, the asynchronous network wait does not block the UI thread.

Use MainActor.run for Short UI Boundaries

If most of the function should remain non-main and only a small part touches UI state, MainActor.run is a clean boundary.

swift
1func refreshStatus(labelUpdater: @MainActor (String) -> Void) async {
2    let result = await heavyComputation()
3
4    await MainActor.run {
5        labelUpdater("Done: \(result)")
6    }
7}

This avoids the larger mistake of marking the whole function @MainActor when only the final update needs main-thread isolation.

Do Not Reach for Task.detached by Default

Task.detached is not the normal answer to "run this in the background." Detached tasks lose some inherited context such as cancellation relationships, priority, and task-local values.

In most app code, a normal Task plus clear actor boundaries is the better design. Use a detached task only when you intentionally want work to break away from the current task tree.

That distinction matters because structured concurrency is one of the main benefits of Swift async and await. If you detach too quickly, you give that structure away.

Off-Main Work Is Not the Same as True Background Execution

This is the platform distinction that matters most. Swift async and await help you write non-blocking code while the app is running. They do not automatically grant extra execution time after the app moves to the background and the system may suspend it.

If you mean real background execution, the correct tools are iOS background-execution APIs such as:

  • 'BGTaskScheduler for scheduled work'
  • background URLSession for longer-running transfers
  • limited app lifecycle background time for short finishing work

So the phrase "best background task solution" must first be split into two questions:

  • how do I keep work off the main actor while the app is active
  • how do I request real background execution from the system

Those are not solved by the same API.

A BGTaskScheduler Example

For actual deferred background work, register a task and then perform the work when the system launches you for it.

swift
1import BackgroundTasks
2
3func registerBackgroundTasks() {
4    BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.example.refresh", using: nil) { task in
5        guard let task = task as? BGAppRefreshTask else { return }
6
7        task.expirationHandler = {
8            task.setTaskCompleted(success: false)
9        }
10
11        Task {
12            do {
13                try await refreshContentFromServer()
14                task.setTaskCompleted(success: true)
15            } catch {
16                task.setTaskCompleted(success: false)
17            }
18        }
19    }
20}

And schedule it:

swift
1import BackgroundTasks
2
3func scheduleRefresh() {
4    let request = BGAppRefreshTaskRequest(identifier: "com.example.refresh")
5    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
6
7    do {
8        try BGTaskScheduler.shared.submit(request)
9    } catch {
10        print("Failed to schedule background refresh: \(error)")
11    }
12}

This is system-managed background execution. It is a different problem from merely keeping CPU or I/O work off the main actor.

Background Networking Has Its Own Tool

If the real task is a network transfer that should continue more reliably while the app is not active, background URLSession is often the better answer than a general concurrency question.

That is why architecture matters. Swift concurrency helps you express asynchronous work clearly, but actual background execution still depends on the platform service designed for that category of work.

A Practical Rule of Thumb

Use this decision guide:

  • UI mutation: @MainActor
  • async I/O or computation while the app is active: normal async functions, not all on the main actor
  • scheduled or deferred system background work: BGTaskScheduler
  • long-running transferable network work: background URLSession

This keeps the code honest about what guarantee it really needs.

Common Pitfalls

One common mistake is marking too much code @MainActor, which can make the program feel more serialized around UI state than necessary.

Another pitfall is assuming async and await automatically give you system background execution when the app is suspended. They do not.

A third issue is using Task.detached reflexively when a normal Task plus actor boundaries would preserve better cancellation and task structure.

Finally, do not use BGTaskScheduler as if it were an immediate guaranteed execution API. It is system-managed and opportunistic, not a manual thread launcher.

Summary

  • Keep expensive work off the main actor and use MainActor only for UI-related state.
  • Use MainActor.run when only a small part of the workflow needs main-actor access.
  • Async and await improve responsiveness, but they do not by themselves provide true iOS background execution.
  • Use BGTaskScheduler or background URLSession when the app needs system-managed background work.
  • Choose the tool based on the guarantee you actually need, not just on the word "background."

Course illustration
Course illustration

All Rights Reserved.