Skip to content
Swift Concurrency Guide: Async/Await, Tasks, GCD, Operations

25 Minutes

Swift Concurrency Guide: Async/Await, Tasks, GCD, Operations

Fix Bugs Faster! Log Collection Made Easy

Get started

Swift concurrency, indeed concurrency for all languages, is essential to a smooth, responsive application. It allows iOS apps to handle various tasks at once and ensures they don’t freeze when handling time-consuming functions.

iOS developers use Swift concurrency for all kinds of tasks and processes, including:

  • Networking (API calls, downloads, uploads).
  • File I/O (reading or writing large files).
  • CPU-heavy work (image filters, JSON parsing, ML inference).
  • Streaming data (timers, sensors, notifications).
  • Multiple operations at once (parallel tasks to save time).

Basically, any time routine work that would otherwise block the main thread.

Swift provides lots of modern tools to maximize the potential benefits of concurrency. This article will unpack all that. We’ll look at modern Swift concurrency with async/await, tasks, and actors, and revisit classic approaches like GCD and OperationQueue to see how they fit into today’s codebases.

First, what is concurrency in Swift?

Concurrency in Swift means writing code that can handle multiple operations at once, without waiting for each one to finish before starting the next.

It’s not always about running things in true parallel. Tasks sometimes take turns on the same thread. But the result is the same: faster, more reliable apps.

Why concurrency matters in Swift

Benefits of concurrencyPitfalls without concurrency
Responsive UI: it remains smooth because work runs in the backgroundFrozen screens: the UI blocks when heavy tasks run on the main thread
Better performance: multiple tasks progress at onceSlow execution: operations run one after the other and feel sluggish
Safer code: tasks and actors reduce common errorsRace conditions: manual threading can lead to deadlocks and crashes
Scalability: apps handle more users and larger data setsCallback hell: nested completion handlers become hard to read and maintain

Concurrency vs parallelism

Concurrency and parallelism are often confused, but they solve different problems.

  • Concurrency is about managing multiple tasks at once, even if they’re not running at the exact same time.
  • Parallelism is about executing multiple tasks simultaneously on different processors or cores.

In Swift, concurrency helps keep apps responsive by structuring tasks. Parallelism provides speed by spreading work across hardware.

What are Swift threads and tasks?

Again, there’s the potential for misinterpretation here. But the distinction is pretty simple.

A thread is the basic unit of execution provided by the operating system. It runs instructions independently, just like an ordinary worker carrying out a single job.

A task is Swift’s higher-level unit of asynchronous work. Think of it like assigning a job to a manager: Swift decides which worker (thread) should run the task, and also handles cancellation and scheduling. Tasks can be cancelled, they use async/await and they’re scheduled automatically.

In Swift, the rule of thumb is simple: use Tasks almost always — use Threads for rare exceptions.

How to use concurrency in Swift

This is particularly significant for iOS developers, because Swift only recently rolled out a modern concurrency system.

We’ve jumped from older patterns like GCD and OperationQueue to the modern async/await, and we’ve got more tools for complex workflows. But this raises a question: how much do we actually need to migrate over?

Many codebases still contain legacy code, so knowing when to utilize modern Swift concurrency is essential.

Here’s a quick breakdown of what it involves:

  • Modern concurrency: async/await, tasks, actors, and structured concurrency for new code.
  • Older approaches: GCD, OperationQueue, and callbacks we still see in legacy projects.
  • Migration: moving older code to Swift concurrency and adopting strict checks in Swift 6.
  • Advanced tools: async sequences, continuations, task-local storage, and executors for complex needs.

Now let’s go deeper. We’ll start with modern concurrency, since it’s the foundation for modern Swift development projects.

Modern Swift concurrency

Modern concurrency was introduced in Swift 5.5 and changed the way we write asynchronous code. Instead of managing callbacks or juggling manual queues, we can now describe concurrent work in a way that reads almost like synchronous code.

The result is code that’s easier to understand, test, and maintain, without giving up performance or flexibility. At the core of this model are:

  • Async functions and the await keyword, which let us write asynchronous code that looks and feels synchronous.
  • Tasks and task groups, which allow us to run work in parallel and manage cancellation together.
  • Actors, which provide a safe way to protect shared state and avoid race conditions, where two or more threads or processes attempt to modify shared data at the same time (more on this below).
  • Structured concurrency with async let , which helps us organize multiple operations in a predictable, controlled way.

Together, these features form the foundation of how most developers approach concurrency in Swift today.

Writing async functions with the await keyword

Async functions let our apps perform slow work like network requests, without blocking the user interface. By marking a function with async, we tell Swift it can pause and resume later.

On the caller side, we use await to wait for the result when it’s ready. This means we can log internal events for debugging while still keeping the code readable and responsive.

In SwiftUI, the returned result can update the UI directly, so the user sees new content once the async work is complete.

import SwiftUI
import Foundation // Needed for Task.sleep

func fetchMessage() async -> String {
    // Simulate a slow operation (2 seconds)
    print("➡️ Sending request to server...")
    try? await Task.sleep(nanoseconds: 2_000_000_000)
    print("✅ Response received")
    return "User profile loaded"   // text shown to the user
}

struct ContentView: View {
    @State private var message = "Loading…" // placeholder

    var body: some View {
        Text(message)
            .task {
                let result = await fetchMessage()
                message = result // update UI once ready
            }
    }
}

#Preview {
    ContentView()
}
  • fetchMessage is marked with async: this tells Swift the function may suspend.
  • Internal logging with print: we see “➡️ Sending request…” and “✅ Response received” in the debug console, simulating a request/response cycle.
  • Task.sleep with await: this simulates network delay without blocking the main thread.
  • Returned string "User profile loaded": this is what the user actually sees in the UI.
  • SwiftUI .task modifier: this runs the async function when the view appears and updates @State with the result.

For more on async and await, watch our YouTube video:

Running parallel work with Task and withTaskGroup

When we need several operations at the same time (e.g. multiple requests), a task group lets us launch work in parallel and await results as they finish.

Each added task is like a lightweight Task scheduled by Swift; the group gives us a safe way to start them together and collect their outputs without blocking.

import SwiftUI
import Foundation

// Simulate async work
func fetch(_ name: String, delay: UInt64) async -> String {
    print("➡️ \\(name): start")
    try? await Task.sleep(nanoseconds: delay) // pretend network latency
    print("✅ \\(name): done")
    return "\\(name) loaded"
}

struct ContentView: View {
    @State private var results: [String] = ["Loading…"]

    var body: some View {
        VStack {
            ForEach(results, id: \\.self) { Text($0) }
        }
        .task {
            await loadAllData() // run as soon as the view appears
        }
    }

    func loadAllData() async {
        results = []
        await withTaskGroup(of: String.self) { group in
            group.addTask { await fetch("Profile",  delay: 800_000_000) }
            group.addTask { await fetch("Settings", delay: 1_200_000_000) }

            for await value in group {
                results.append(value) // UI updates as results come in
            }
        }
    }
}

#Preview { ContentView() }

How this works:

  • group.addTask { … } starts each unit of work in parallel (each is a lightweight Task).
  • for await value in group streams results as tasks finish (order is completion order).
  • Task.sleep just simulates latency; replace with real async calls (network, disk, etc.).
  • Everything is non-blocking: the runtime schedules tasks across threads for us.

Protecting shared state with actors

When several tasks update the same variable at once, results can get corrupted: values may be lost or overwritten. As briefly mentioned earlier, this is called a race condition, where the outcome depends on which task “wins” the timing race, leading to unpredictable results.

Imagine one task performing count += 1 and another performing count *= 2. If both start from 10, the final result depends on timing. One order gives 21, the other 22, and without protection you may even lose one of the updates.

import SwiftUI

actor Counter {
    var value = 10
    func addOne() { value += 1 }
    func double() { value *= 2 }
}

struct ContentView: View {
    @State private var result = 0
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Final result: \\(result)")
            Button("Run Actor Demo") {
                Task {
                    let counter = Counter()
                    
                    // Start both tasks at the same time
                    async let add: Void = counter.addOne()
                    async let mul: Void = counter.double()
                    
                    // Wait for both to finish
                    _ = await (add, mul)
                    
                    result = await counter.value
                }
            }
        }
    }
}

#Preview {
    ContentView()
}

Actors solve the problem. When you add the variable “counter”, it isolates/protects the actor/variable’s state. This means only one task at a time can read or write, while others wait their turn. This ensures consistency, even when tasks run in parallel.

Organizing tasks using structured concurrency (async let)

async let works together with async and await to create concurrent child tasks within the scope of a function, and organize your code more neatly.

Instead of starting tasks manually and juggling their lifetimes, you declare them where they belong. Swift ensures they finish before leaving scope. This is fundamental to structured concurrency: tasks are created and awaited in a controlled way, so they don’t leak or run forever in the background.

  • Structured concurrency: tasks are tied to their scope, and automatically canceled when no longer needed.
  • async let: this starts work immediately and lets you await the result later, keeping code clean and predictable.

Imagine fetching two pieces of data at once and combining the results:

import SwiftUI

func fetchNumber(_ value: Int, delay: UInt64) async -> Int {
    try? await Task.sleep(nanoseconds: delay)
    return value
}

struct ContentView: View {
    @State private var result = 0

    var body: some View {
        VStack(spacing: 20) {
            Text("Total: \\(result)")
            Button("Run async let Demo") {
                Task {
                    // Start both tasks at the same time
                    async let first: Int = fetchNumber(10, delay: 1_000_000_000) // 1s
                    async let second: Int = fetchNumber(20, delay: 2_000_000_000) // 2s

                    // Wait for both results, then combine
                    result = await (first + second)
                }
            }
        }
    }
}

#Preview {
    ContentView()
}

How it works

  • Tapping Run async let Demo starts two async operations at the same time.
  • They’re declared with async let, so Swift makes sure both finish before the scope ends.
  • After ~2 seconds, the total appears (30).
  • This is structured concurrency: tasks are scoped, predictable, and automatically canceled if the scope exits early.

Classic concurrency in Swift

Now let’s move onto classic concurrency. Right up to 2021, when Swift 5.5 introduced async/await, developers managed concurrency with the following techniques:

  • Callbacks. Closures passed into a function, executed later when the work finishes. Useful but often led to “callback hell”, with deeply nested and hard-to-read code.
  • Grand Central Dispatch (GCD). Apple’s low-level API managed tasks manually by providing a set of queues and mechanisms to handle concurrency in the background. Powerful, but error-prone.
  • OperationQueue. Higher-level than GCD, with features like dependencies and cancellation, but still clunky.
  • Drawbacks. One of the least efficient methods of all. Debugging was difficult, cancellation inconsistent, and race conditions common.

All these techniques are now deprecated, and we recommend using Swift’s structured concurrency as your go-to. However if you work on older projects, it’s highly likely you’ll run into some of the code below.

Grand Central Dispatch and callbacks

The most common pattern was to use callbacks combined with Grand Central Dispatch (GCD) , like this.

import SwiftUI

// GCD + callback
func fetchData(completion: @Sendable @escaping (String) -> Void) {
    DispatchQueue.global().async {
        sleep(1) // simulate slow work
        completion("Data loaded")
    }
}

struct ContentView: View {
    @State private var result = "Waiting..."

    var body: some View {
        VStack(spacing: 16) {
            Text(result)
            Button("Fetch Data (GCD + callback)") {
                fetchData { value in
                    Task { await MainActor.run { result = value } }
                }
            }
        }
    }
}

#Preview {
    ContentView()
}

The method was simple, flexible and backed by tons of documentation. But it created deeply nested code that was hard to format and easy to break.

Operation and OperationQueue

Operation and OperationQueue were Apple’s higher-level alternative to GCD.

Instead of juggling raw queues, you wrapped work inside Operation objects and let the queue manage execution. This was simpler and more structured than plain old GCD, and made it easier to add dependencies between tasks, cancel work or adjust priorities.

  • Operation – encapsulates a unit of work you can start, cancel, or prioritize.
  • OperationQueue – runs multiple operations concurrently without manual thread control.
  • Dependencies – one operation can wait for another to finish.
  • Cancellation & priorities – you can stop tasks or run important ones first.
let queue = OperationQueue()

let opA = BlockOperation { print("Step A") }
let opB = BlockOperation { print("Step B") }

opB.addDependency(opA)   // B waits for A
opB.queuePriority = .high

queue.addOperations([opA, opB], waitUntilFinished: false)

This was a step up. Nonetheless it required you to wrestle with several layers of structure and created lots of moving parts to manage. This could often lead to errors, which brought deadlocks and screen-freeze.

A quick recap on deprecated approaches in concurrency

In case you need additional clarity on why Swift 5.5 was such a big step forward, here’s a final rundown.

Deprecated / LegacyModern Substitute
Callbacks with completion handlersasync/await functions
DispatchQueue.async (GCD)Task { ... } and structured concurrency
DispatchGroup for waiting on taskswithTaskGroup or async let
DispatchSemaphore for sync coordinationActors or structured concurrency (await)
Operation / OperationQueueTask, TaskGroup, task priorities
Manual locks (NSLock, pthread_mutex)Actors (isolation) or Sendable value types

Modern Swift concurrency enforces structured lifetimes, integrates cancellation and eliminates most race conditions without manual locks. Instead of juggling callbacks, queues and semaphores, we declare work with async/await and let Swift schedule it predictably.

Migrating apps to modern Swift concurrency

Good news: Migrating doesn’t require a full rewrite. We can modernize incrementally and safely by wrapping old patterns step by step and move toward async/await without breaking existing logic.

Here are some of the most valuable calls in our migration playbook:

  • Wrap callbacks into async functions (we’ll look at continuations in the advanced section).
  • Replace DispatchGroup with structured tools like withTaskGroup or async let.
  • Swap locks and semaphores for safer isolation models like actors (explained later).
  • Adopt Task and priorities instead of juggling GCD queues.

For a complete step-by-step walkthrough, see our detailed migrating guide.

Advanced concurrency features in Swift

When you’ve mastered async/await, you’ve gone a long way to nailing concurrency in Swift. But there are also plenty of other levers you can pull to unlock the maximum benefit.

Here are some advanced Swift concurrency tools:

  • Async sequences and streams – handle values delivered over time, like user events or network updates.
  • Continuations (checked vs unsafe) – bridge old callback-style APIs with modern async code.
  • Executors and priorities – control where tasks run and how urgent they are.
  • Task-local values – pass context safely without relying on global state.
  • Distributed actors and Swift 6 algorithms – scale concurrency across processes or machines while keeping safety guarantees.

As you gain confidence in Swift concurrency, you can start deploying these features to deal with complex scenarios and add even more efficiency to your Swift application.

Async sequences and streams

Async sequences let you work with values that arrive over time, like notifications, user input, or live data from the network.

Instead of callbacks, you use an async for loop to process each value as it comes in. This keeps code clean, easy to read, and naturally fits Swift’s concurrency model.

As a simple example, let’s imagine a timer that emits ticks:

let stream = AsyncStream { continuation in
    Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
        continuation.yield(Date())
    }
}

Task {
    for await tick in stream {
        print("⏰ Tick at \\(tick)")
    }
}

If you want to see this update live in SwiftUI Preview, here’s a ready-to-use version:

import SwiftUI

struct ContentView: View {
    @State private var tick: String = "—"

    var body: some View {
        Text("Last tick: \\(tick)")
            .task {
                let stream = AsyncStream<Date> { continuation in
                    Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
                        continuation.yield(Date())
                    }
                }

                for await date in stream {
                    tick = date.formatted(date: .omitted, time: .standard)
                }
            }
    }
}

#Preview {
    ContentView()
}

Continuations (checked vs unsafe)

Continuations let you bridge old callback-based code with Swift’s modern async/await. Instead of nesting callbacks, you can turn them into async functions and use them like any other task.

This keeps code clean and integrates legacy APIs into structured concurrency. Swift provides two options:

  • checked continuations (which are safer and detect misuse).
  • unsafe continuations (which are faster, but without safety checks).

Here’s a simple example, turning a completion-handler API into async:

func fetchNumber(completion: @escaping (Int) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        completion(42)
    }
}

func fetchNumberAsync() async -> Int {
    await withCheckedContinuation { continuation in
        fetchNumber { value in
            continuation.resume(returning: value)
        }
    }
}

Task {
    let result = await fetchNumberAsync()
    print("✅ Got:", result)
}

Now here’s the code from SwiftUI:

import SwiftUI

func fetchNumber(completion: @escaping (Int) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        completion(42)
    }
}

func fetchNumberAsync() async -> Int {
    await withCheckedContinuation { continuation in
        fetchNumber { value in
            continuation.resume(returning: value)
        }
    }
}

struct ContentView: View {
    @State private var result = "—"

    var body: some View {
        VStack(spacing: 20) {
            Text("Result: \\(result)")
            Button("Fetch number") {
                Task {
                    let value = await fetchNumberAsync()
                    result = "\\(value)"
                }
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

Tip: Use withCheckedContinuation in almost all cases — it catches mistakes like resuming twice. withUnsafeContinuation is faster but skips safety checks, so only use it when performance is critical and you know the API’s behavior.

Executors and priorities

In Swift, every task needs to know where to run.

That’s what an executor does: it decides which thread the work runs on.

By default, tasks use a shared system pool (the global executor). But for anything that touches the UI, we use the MainActor executor, which always runs on the main thread, the only place SwiftUI or UIKit can update views safely.

Then we have priorities, which tell Swift how important a task is.

For example:

  • .background → low urgency (good for heavy or invisible work)
  • .userInitiated → high urgency (good for user-triggered actions)

Together, executors decide where work happens, and priorities decide when it should happen.

That’s how Swift keeps your app fast and smooth, without you having to manually juggling threads.

Task(priority: .background) {
    // Heavy calculation, won’t block UI (default executor)
    let result = (1...1_000_000).reduce(0, +)
    await MainActor.run {                      // hop to MainActor executor
        print("UI updated with result: \\(result)")
    }
}

Task(priority: .userInitiated) {
    // Runs sooner—good for user actions
    print("⚡ User tapped a button")
}
PriorityWhen to use it
.highVery urgent, system-level or performance-critical work. Rarely used directly in apps.
.userInitiatedFor tasks the user is waiting for — e.g. tapping a button, searching, loading data.
.mediumDefault if no priority is given. Good for general operations.
.lowFor less important work like caching or cleanup.
.backgroundLowest priority — ideal for maintenance, syncing, or analytics.
.utility (GCD only)Used in older GCD code for long-running tasks that don’t need immediate results. Replaced by .low or .background in Swift concurrency.

Task-local values

Sometimes tasks need context like a user ID or session token, without relying on unsafe global variables.

Task-local values meet this need by letting us attach values that automatically flow down to child tasks but stay isolated from unrelated ones. This avoids unsafe shared states while keeping context accessible.

@TaskLocal static var userID: String?          // define a task-local value

Task {
    // Bind a value for this task and its children
    await $userID.withValue("Alice") {
        print("Parent:", userID ?? "none")     // "Alice"
        
        await Task {
            // Child inherits the bound value
            print("Child:", userID ?? "none")  // "Alice"
        }.value
    }
}

Task {
    // No binding here, so the default is used
    print("Unrelated:", userID ?? "none")      // "none"
}

Task-local values are great for providing scoped context: safe, inheritable, and automatically cleaned up.

Distributed actors and Swift 6 algorithms

Swift concurrency now supports distributed actors, which extend the actor model across process or network boundaries.

They let us call methods on remote objects as if they were local, while Swift handles serialization, transport, and isolation under the hood.

In parallel, Swift 6, released in September 2024, introduces async algorithms—ready-made building blocks like map, filter, and reduce that work directly with async sequences, making asynchronous data pipelines much easier to compose.

// A distributed actor that could live on another process/machine
distributed actor Greeter {
    distributed func hello(name: String) -> String {
        "Hello, \\(name)!"
    }
}

// Async algorithms work naturally with async sequences
let numbers = [1, 2, 3].async // hypothetical async sequence
for try await doubled in numbers.map({ $0 * 2 }) {
    print(doubled)  // 2, 4, 6
}

Together, distributed actors and async algorithms scale concurrency beyond one process, while keeping code safe and expressive.

Swift concurrency summary

Swift concurrency covers everything from older patterns like callbacks and GCD to modern async/await and advanced features in Swift 6. Classic APIs still appear in legacy projects but are best replaced with structured concurrency.

Modern tools form the foundation for new code, while advanced features handle complex workflows and scaling.

Keyword / FeatureDescription & When to Use
CallbacksOld async style; wrap with continuations when migrating.
DispatchQueue (GCD)Run work on background threads; now replaced by Task.
DispatchGroupWait for multiple tasks; replace with withTaskGroup or async let.
OperationQueueHigher-level GCD wrapper; use tasks unless you need KVO/dependencies.
Async/awaitModern standard; write async code that looks synchronous.
Tasks & Task groupsRun work concurrently; use for structured parallelism.
ActorsProtect shared state safely; replace locks and semaphores.
Async sequences/streamsHandle values arriving over time (e.g., events, live data).
ContinuationsBridge callbacks to async; use checked continuations for safety.
Executors & prioritiesControl where tasks run (default, MainActor) and how urgent they are.
Task-local valuesStore context (like IDs) per task; flows to children automatically.
Distributed actorsActor model across processes/machines; scale concurrency.

If you remember these key points, you’ll not only have a blast with Swift concurrency but also write safer, faster, and more future-proof code. Happy coding!

Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Flávio Silvério

Interested in how things work since I was little, I love to try and figure out simple ways to solve complex issues. On my free time you'll be able to find me with the family strolling around a park, or trying to learn something new. You can contact him on Linkedin or GitHub

Join thousands of developers
and start fixing bugs faster than ever.