---
title: "Swift concurrency: goodbye event loop. async let, TaskGroup, actors and Swift 6"
description: "Swift Concurrency for people coming from JavaScript: why there is no event loop, how async let and TaskGroup replace Promise.all, what actors and @MainActor are, what Sendable requires and why in Swift 6 a data race is a compile error."
author: Ramón Chancay
date: 2026-09-07
lang: en
tags: [Swift, Concurrency, async/await, Actors, Swift 6, Sendable, iOS]
canonical: https://www.ramonchancay.me/blog/swift-concurrency-goodbye-event-loop
---

# Swift concurrency: goodbye event loop. async let, TaskGroup, actors and Swift 6

JavaScript has a single thread and an event loop, and that restriction is also its guarantee: two functions never run at the same time, so a shared object never gets corrupted halfway through a write. `async/await` in JavaScript is sugar over promises that resolve when the loop gives them a turn. Swift uses the same words, `async` and `await`, on top of a completely different model: there are several real threads, the code after an `await` can continue on a different one, and two tasks can touch the same data at the same time. Swift 6 turns that risk into a compile error. This is the most technical post in the series because the concurrency model is the part of Swift that looks like nothing you have used in React Native, and also the one that, once understood, gives you the most confidence in your code.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> TL;DR <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li>There is no event loop: <code>await</code> suspends the function and frees the thread, and the continuation may run on another thread. <code>async let</code> and <code>TaskGroup</code> are your <code>Promise.all</code>, but with real parallelism and cancellation that flows down to the children.</li>
<li>An actor is a class whose state is only touched from inside, one call at a time. <code>@MainActor</code> is the actor of the main thread, where the UI lives. <code>Sendable</code> is the mark that a value can cross between actors safely.</li>
<li>With Swift 6 strict mode, sharing mutable data between tasks without protection does not compile. The errors are noisy at first and worth every minute: they remove the hardest class of bug to reproduce.</li>
</ul>
</div>
</details>

**In this article:**

- **The model** — [No event loop](#no-event-loop-what-await-means-in-swift) · [Tasks and structured concurrency](#tasks-and-structured-concurrency-async-let-and-taskgroup)
- **Isolation** — [Actors and @MainActor](#actors-and-mainactor-state-that-is-only-touched-from-inside) · [Sendable](#sendable-what-can-cross-the-boundary)
- **In practice** — [Strict Swift 6](#swift-6-the-data-race-as-a-compile-error) · [Cancellation](#structured-cancellation-versus-abortcontroller) · [Common mistakes](#the-common-mistakes-when-coming-from-javascript)

## No event loop: what await means in Swift

In JavaScript, `await` means "hand control back to the event loop and continue when the promise resolves". Since there is a single thread, when you continue you know nobody else touched anything *while* you were waiting: any state you read after the `await` may have been changed by another task that ran during that turn, but not *simultaneously*.

In Swift, `await` marks a suspension point: the function pauses, the thread is freed for other tasks, and when the result is ready the function continues on some thread of the cooperative pool, not necessarily the same one. There are as many threads as cores, and two `async` functions can be executing at the same time, on different cores.

```swift
func loadDashboard() async throws -> Dashboard {
    let user = try await api.user()          // suspension: the thread is freed
    // You may be on a different thread here.
    let orders = try await api.orders()      // another suspension
    return Dashboard(user: user, orders: orders)
}
```

The two consequences that change everything:

1. **Sequential calls are still sequential.** The code above waits for the user and then asks for the orders, just like in JavaScript. To run them at the same time you have to ask for it explicitly, and that is the next section.
2. **Shared state is genuinely dangerous.** If two tasks modify the same array from different threads, the result is not "one wins": it is corrupted memory and a crash with no useful stack trace. JavaScript did not have this problem because it could not have it. Swift solves it with actors and with the compiler, not with discipline.

One part is the same: an `async` function can only be called from an `async` context, and the entry point from synchronous code is `Task { ... }`, the equivalent of calling an `async` function without `await` in JavaScript. In SwiftUI, `.task { }` is that entry point with automatic cancellation when the screen goes away.

## Tasks and structured concurrency: async let and TaskGroup

`Promise.all([a(), b()])` has two translations, depending on whether you know how many operations there are at compile time.

**`async let`** when the number is fixed. Each `async let` starts a child task immediately; the later `await` collects the results:

```swift
func loadDashboard() async throws -> Dashboard {
    async let user = api.user()              // starts now
    async let orders = api.orders()          // starts now, in parallel
    async let promos = api.promotions()
    return try await Dashboard(user: user, orders: orders, promos: promos)
}
```

**`TaskGroup`** when the number is dynamic, like a `Promise.all(ids.map(fetch))`:

```swift
func loadThumbnails(for ids: [Photo.ID]) async throws -> [Photo.ID: UIImage] {
    try await withThrowingTaskGroup(of: (Photo.ID, UIImage).self) { group in
        for id in ids {
            group.addTask { (id, try await api.thumbnail(id)) }
        }
        var result: [Photo.ID: UIImage] = [:]
        for try await (id, image) in group {     // they arrive in completion order
            result[id] = image
        }
        return result
    }
}
```

The difference from `Promise.all` is not in the syntax but in the word "structured". The child tasks of an `async let` or a `TaskGroup` **cannot outlive the function that created them**. If the function returns, throws or is cancelled, the children are cancelled and awaited before it exits. There is no promise left running after the component unmounted, and no `useEffect` updating the state of a screen that is no longer there. The task tree mirrors the call tree.

```text
loadDashboard()
   │
   ├── async let user     ──► api.user()
   ├── async let orders   ──► api.orders()
   └── async let promos   ──► api.promotions()
   │
   ▼ (waits for all three; if one throws, cancels the other two and propagates)
return Dashboard
```

`Task { }` and `Task.detached { }` are the *unstructured* tasks: they live on their own and you have to store and cancel them by hand. They are used at the edges (the `@main`, a button, a notification handler), not inside the logic.

## Actors and @MainActor: state that is only touched from inside

An actor is a reference type whose mutable state can only be read or modified from inside the actor itself, and the actor executes one operation at a time. From outside, every access is an `await`, because the actor may be busy:

```swift
actor ImageCache {
    private var storage: [URL: UIImage] = [:]

    func image(for url: URL) -> UIImage? {
        storage[url]
    }

    func store(_ image: UIImage, for url: URL) {
        storage[url] = image
    }
}

let cache = ImageCache()
await cache.store(image, for: url)      // await: enters the actor when it is free
let cached = await cache.image(for: url)
```

It is the solution to the problem from the previous section: the dictionary cannot get corrupted because two threads never touch it at once. In JavaScript, every object was an implicit actor because there was a single thread. In Swift, you declare it.

One detail that takes getting used to: inside an actor method, an `await` on something external suspends the method and **lets other calls in**. The state may have changed by the time the method continues. It is the same behavior JavaScript has after an `await`, and the rule is the same: do not assume that what you read before the `await` is still true after it.

```swift
actor ImageCache {
    func load(_ url: URL) async throws -> UIImage {
        if let cached = storage[url] { return cached }
        let image = try await downloader.fetch(url)   // other calls get in here
        storage[url] = image                            // may overwrite one that already got in
        return image
    }
}
```

To deduplicate properly, you store the in-flight `Task` per key and await that task if it already exists. It is the pattern the [data post](/blog/data-in-swift-what-tanstack-query-did-for-you) mentioned to avoid two simultaneous `refresh()` calls.

**`@MainActor`** is a global actor that represents the main thread. Everything that touches the UI lives there: SwiftUI views, the ViewModels they observe, anything from UIKit. Marking a class with `@MainActor` guarantees that its methods and properties are only accessed from the main thread, and the compiler forces an `await` to enter from outside:

```swift
@Observable
@MainActor
final class OrdersViewModel {
    private(set) var orders: [Order] = []

    func load() async {
        // This await runs on the pool; the actor is free meanwhile.
        let fetched = try? await repository.fetchAll()
        // The continuation comes back to the MainActor: assigning here is safe.
        orders = fetched ?? []
    }
}
```

This replaces the `DispatchQueue.main.async` you would see in older code, and the question "am I on the main thread?" that did not exist in React Native because the bridge already handled it. With `@MainActor` the answer comes from the type, not from the call site.

## Sendable: what can cross the boundary

If an actor protects its state, what about the values that go in and out of it? A `struct` made only of values is copied, and the copy is safe. A `class` with mutable properties is not: the actor would receive a reference that another thread could be modifying.

`Sendable` is the protocol that marks "this type can cross between isolation domains safely". `struct` and `enum` types whose properties are `Sendable` are `Sendable` automatically. Classes are only if they are `final` and immutable (`let`), or protect their own state (for example, an internal lock, declared with `@unchecked Sendable` on your own responsibility). Actors are `Sendable` by definition. Closures that cross are marked `@Sendable` and can only capture `Sendable` values.

```swift
struct Order: Sendable {              // value struct: automatically Sendable
    let id: String
    let items: [OrderItem]            // requires OrderItem: Sendable
}

final class Session: Sendable {       // class: only if everything is immutable
    let token: String
    let expiresAt: Date
}

@Observable
final class CartStore { ... }         // not Sendable: mutable and with no actor
```

This is why the Swift post insisted on modeling with `struct`: not only for value semantics, but because `struct` values cross actors for free. A model made of mutable classes is a model the Swift 6 compiler will reject at every boundary.

## Swift 6: the data race as a compile error

With the language in Swift 6 mode (the `-strict-concurrency=complete` compiler option, which is the default in a new project with Swift 6), the compiler verifies all of the above and **rejects code that could produce a data race**:

- Passing a non-`Sendable` class to a `Task` or to an actor.
- Capturing a mutable variable in a `@Sendable` closure.
- Touching a `@MainActor` property from a context that is not on it without `await`.
- A mutable global variable with no isolation (a module-level `var`).

Each of these used to be an intermittent crash in production that would not reproduce in development. Now they are red lines in Xcode before anything runs.

```swift
// Swift 6: error. `logger` is a mutable class captured by a Sendable closure.
final class Logger { var lines: [String] = [] }
let logger = Logger()
Task { logger.lines.append("hello") }

// Fix: make it an actor.
actor Logger {
    private var lines: [String] = []
    func log(_ line: String) { lines.append(line) }
}
let logger = Logger()
Task { await logger.log("hello") }
```

Migrating an existing project to Swift 6 is a noisy experience: dozens or hundreds of errors on the first build, most of them from the same family. The path that works is module by module: turn strict mode on package by package (one more reason for [modularization](/blog/state-and-architecture-from-zustand-to-observable-and-tca)), starting with the domain (all `struct`, zero errors) and finishing with the app. In a new project, you start on Swift 6 and the errors arrive one at a time, the moment you write the code that causes them.

Swift 6.2 added an important option to cut that noise: **`MainActor` isolation by default** for a module (`-default-isolation MainActor`). With it, everything that is not marked is assumed to be on the main thread, which is where most of an app's code lives, and you only declare explicitly what leaves it (`nonisolated` or an actor of your own). For an app target, it is the configuration I recommend from day one; for a networking or persistence package, it is not.

> After migrating a medium-sized project to strict Swift 6, the feeling was not of having fixed bugs but of having discovered how many there were: every compiler error pointed at a place where a mutable class crossed a thread without anyone knowing. None of them had been reported as a crash yet.

## Structured cancellation versus AbortController

In JavaScript, cancelling a `fetch` requires creating an `AbortController`, passing its `signal`, calling `abort()` and cleaning up in the `useEffect` cleanup. And cancellation only reaches the operations you passed the `signal` to.

In Swift, cancellation is **cooperative and structured**. Cancelling a task marks the task and all its children as cancelled. System operations that suspend (`URLSession`, `Task.sleep`) throw `CancellationError` on resumption if their task is cancelled. Your own code checks with `Task.checkCancellation()` or `Task.isCancelled` at the points where it makes sense:

```swift
func processAll(_ items: [Item]) async throws -> [Result] {
    var results: [Result] = []
    for item in items {
        try Task.checkCancellation()          // exits with CancellationError if cancelled
        results.append(try await process(item))
    }
    return results
}
```

And in SwiftUI, `.task { }` cancels on its own when the view disappears, and `.task(id:)` cancels and restarts when the id changes. It is the `useEffect` with cleanup and `AbortController`, without writing either:

```swift
struct SearchScreen: View {
    @State private var query = ""
    @State private var results: [Product] = []

    var body: some View {
        List(results) { ProductRow(product: $0) }
            .searchable(text: $query)
            .task(id: query) {
                // Every query change cancels the previous search and starts this one.
                try? await Task.sleep(for: .milliseconds(300))   // debounce
                guard !Task.isCancelled else { return }
                results = (try? await api.search(query)) ?? []
            }
    }
}
```

That block is the debounce with cancellation that in React Native took a custom hook with `useRef`, `setTimeout`, `clearTimeout` and an `AbortController`. Here cancellation is part of the model: the `sleep` throws if cancelled, and the network request is never fired.

## The common mistakes when coming from JavaScript

1. **Putting `await` in series when you wanted parallel.** Two consecutive `await` calls are sequential. If they do not depend on each other, use `async let`.
2. **Using `Task { }` for everything.** An unstructured `Task` inside an `async` method breaks cancellation and error handling. Inside `async` code, use `async let` or `TaskGroup`; `Task { }` only at the synchronous edges.
3. **Capturing mutable `self` in a `Task` from a ViewModel without `@MainActor`.** Swift 6 rejects it. Mark the ViewModel with `@MainActor` and the problem goes away.
4. **Making an `@Observable` an actor.** It does not work well: SwiftUI needs to read the properties synchronously in `body`. Objects the UI observes go on `@MainActor`, and heavy work is delegated to your own actors or to `nonisolated` functions.
5. **Forgetting that an actor "re-enters" at every `await`.** A check before the `await` does not hold after it. Store the in-flight task or check again.
6. **Blocking the thread with heavy synchronous work inside an `async` function.** The pool has as many threads as cores; a one-second loop on one of them slows down the rest. CPU work is split up, or marked `nonisolated` and run in a `Task.detached` with low priority.
7. **Synchronizing with `DispatchQueue` or semaphores from `async` code.** Mixing the two models produces deadlocks the runtime cannot detect. Inside `async`, only actors and `await`.

## Frequently asked questions

### So `DispatchQueue` and GCD are no longer used?

In new code, almost never. They exist, they are compatible, and you will see them in libraries and examples from before 2021. The rule I follow: inside a module written with `async/await`, `DispatchQueue` does not appear; at the boundary with an API that only offers callbacks, `withCheckedContinuation` converts it to `async` once, and the rest of the module never knows.

### How do I convert a callback-based API or a delegate to async/await?

With `withCheckedThrowingContinuation` for a single call (a callback that is invoked exactly once) and with `AsyncStream` for repeated events (a delegate that emits several times). The hard rule: the continuation is resumed exactly once; resuming it zero or two times is a crash in development, which is what "checked" means.

### Are actors slow? Should I avoid `await` in hot paths?

An actor hop costs more than a direct call, but it is on the order of microseconds. Where it does show is in loops with thousands of `await` calls to an actor: there it is better to expose a method that does the work in a batch inside the actor, instead of a thousand entries and exits. For the rest of an app, it is not a concern.

### Doesn't `@MainActor` on the whole ViewModel block the UI?

No. `@MainActor` says where the ViewModel's *synchronous* code runs, not where it waits. When the ViewModel does `await repository.fetchAll()`, the main thread is free during the wait; only the assignment of the result comes back to the main thread, which is exactly what you want. What blocks is synchronous CPU work inside the ViewModel, and that moves to an actor or to a `nonisolated` function.

### How do I test concurrent code?

`async` functions are tested with `async` tests (Swift Testing and XCTest both support them). To control time, you inject a clock (`any Clock`) instead of calling `Task.sleep` directly, and the test uses a test clock that advances by hand. For actors, you test the behavior observable from outside; the exclusion guarantee comes from the language, there is no need to test it. The [testing post](/blog/ios-testing-and-tooling-for-seniors) goes into detail.

## Conclusion

Swift uses `async` and `await` like JavaScript, but without an event loop: there are real threads, the continuation can change threads, and shared state can get corrupted. The language's answer is a system, not a convention: structured tasks whose lifecycle follows the function that created them, actors that execute one operation at a time, `@MainActor` for everything that touches the UI, `Sendable` for what can cross boundaries, and a compiler that in Swift 6 rejects code that could produce a data race. Cancellation stops being an `AbortController` you pass around by hand and becomes a property of the task tree.

To get started: turn on strict Swift 6 from the first commit, mark ViewModels with `@MainActor`, model the domain with `struct` so it is `Sendable` with no effort, use `async let` when two calls do not depend on each other, and rely on `.task` to cancel. The [next post](/blog/ios-persistence-swiftdata-grdb-keychain) goes down to disk: SwiftData, GRDB, Core Data and Keychain, with the decision of which one to use and why Realm stopped being an option.
