Mastering Swift Concurrency with Antigravity — async/await, Actors, and Structured Concurrency in Production iOS Apps
A practical, production-focused walkthrough of Swift Concurrency's async/await, Actor model, and Structured Concurrency with Antigravity AI assistance. Build production-quality iOS apps free from data races and callback hell.
Setup and context — How Swift Concurrency Changed iOS Development
Asynchronous programming in iOS has long had a reputation for complexity. Callback hell, the tangled web of DispatchQueue management, nested completion handlers — these challenges have frustrated iOS developers for years.
Swift Concurrency, introduced in Swift 5.5, fundamentally changed the game. The async/await syntax brings clarity to asynchronous code. The Actor type eliminates data races at the compiler level. Structured Concurrency provides automatic lifecycle management for tasks.
Yet Swift Concurrency has its own learning curve. @MainActor, @Sendable, task groups, AsyncSequence — there's a lot to absorb, and it's not always obvious where to start.
That's where Antigravity shines. The AI agent can generate Swift Concurrency boilerplate on demand, answer architectural questions, and help track down elusive concurrency bugs. This guide walks you through mastering Swift Concurrency systematically, using Antigravity as your accelerator, with practical code examples throughout.
The Three Pillars of Swift Concurrency
Swift Concurrency rests on three core concepts that work together:
① async/await: Syntax that lets you write asynchronous code in a linear, synchronous style. Eliminates callback hell and dramatically improves readability.
② Actor: A reference type that serializes access to its internal mutable state, preventing data races. Similar to a class, but the compiler enforces safe concurrent access automatically.
③ Structured Concurrency: A system for managing the lifecycle of Tasks in a tree structure. Task cancellation and error propagation flow automatically through the hierarchy.
To get a quick overview in Antigravity, try asking:
Ask Antigravity:
"Explain how async/await, Actors, and Structured Concurrency relate to each other
in Swift, with typical iOS use cases and code examples for each."
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦How async let and TaskGroup cut a real API bootstrap from 1,180ms to 420ms (2.8x)
✦The actor reentrancy trap that silently corrupts cache state, and the task-sharing fix
✦How a Swift 6 migration nearly eliminated data-race crashes in a 50M-download app
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Let's see the difference between traditional callback style and async/await:
// ❌ Traditional callback style — hard to read, complex error handlingfunc fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) { URLSession.shared.dataTask(with: userURL(id: id)) { data, response, error in if let error = error { completion(.failure(error)) return } guard let data = data else { completion(.failure(NetworkError.noData)) return } do { let user = try JSONDecoder().decode(User.self, from: data) completion(.success(user)) } catch { completion(.failure(error)) } }.resume()}// ✅ async/await style — intuitive, top-to-bottom flowfunc fetchUser(id: String) async throws -> User { let (data, _) = try await URLSession.shared.data(from: userURL(id: id)) return try JSONDecoder().decode(User.self, from: data)}
With async/await, errors are unified under throws, and the code reads naturally from top to bottom — no more right-ward drift.
Chaining Async Operations
Where async/await truly earns its keep is when composing multiple async calls:
// Fetch user info and display their full profilefunc loadUserProfile(userId: String) async throws -> UserProfile { // Sequential — use when results depend on each other let user = try await fetchUser(id: userId) let posts = try await fetchPosts(authorId: user.id) let followers = try await fetchFollowers(userId: user.id) return UserProfile(user: user, posts: posts, followers: followers)}
This runs sequentially. If those three calls are independent, async let (covered later) can run them in parallel.
Migrating Callbacks to async/await with Antigravity
When migrating legacy callback-based code, Antigravity is a huge time-saver:
Ask Antigravity:
"Please migrate the following callback-based code to async/await,
including proper error handling and cancellation support."
[paste your code]
Antigravity will generate bridge code using withCheckedThrowingContinuation:
// Bridging a callback API to async/awaitfunc fetchLegacyData() async throws -> Data { try await withCheckedThrowingContinuation { continuation in legacyAPI.fetchData { result in continuation.resume(with: result) } }}
The Actor Model — Compile-Time Data Race Prevention
Why Actors Exist
When multiple threads access the same mutable state simultaneously, data races occur. These cause crashes and unpredictable behavior, and they're notoriously hard to reproduce and debug.
Swift's actor type automatically serializes access to its mutable state, preventing data races at the compiler level — no locks required.
Methods inside an actor belong to its isolation domain by default. Pure computations that don't touch actor state can be marked nonisolated, allowing callers to invoke them without await:
actor UserRepository { private var users: [String: User] = [:] // Actor-isolated: requires await from outside func findUser(id: String) -> User? { return users[id] } // nonisolated: no await needed (doesn't access actor state) nonisolated func validateEmail(_ email: String) -> Bool { let emailRegex = #"^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"# return email.range(of: emailRegex, options: .regularExpression) != nil }}
@MainActor — Guaranteed Main Thread Execution
@MainActor is a special global actor that guarantees execution on the main thread. Since all SwiftUI and UIKit updates must happen on the main thread, annotating your View Models with @MainActor is standard practice:
@MainActorclass ArticleViewModel: ObservableObject { @Published var articles: [Article] = [] @Published var isLoading = false @Published var errorMessage: String? private let repository: ArticleRepository init(repository: ArticleRepository) { self.repository = repository } func loadArticles() async { isLoading = true errorMessage = nil do { // repository.fetchAll() may run off the main thread let fetchedArticles = try await repository.fetchAll() // @MainActor ensures this property update happens on the main thread articles = fetchedArticles } catch { errorMessage = error.localizedDescription } isLoading = false }}
Ask Antigravity to "design a SwiftUI ViewModel for async data loading" and it will generate the @MainActor-annotated pattern correctly from the start.
Structured Concurrency — Creating and Managing Tasks
Task Basics
Task is the fundamental unit of concurrent work in Swift Concurrency, creating a new async execution context:
// Using .task modifier in SwiftUI — auto-cancels when view disappearsstruct ContentView: View { @State private var imageData: Data? var body: some View { Group { if let data = imageData { Image(uiImage: UIImage(data: data)!) } else { ProgressView() } } .task { do { let (data, _) = try await URLSession.shared.data(from: imageURL) imageData = data } catch { print("Error: \(error)") } } }}
The .task modifier ties the Task lifecycle to the View — it's automatically cancelled when the View disappears.
async let — Parallel Execution Made Simple
Use async let when multiple async operations are independent and can run concurrently:
func loadDashboard() async throws -> Dashboard { // These three API calls run in parallel (~3x faster than sequential) async let userProfile = fetchUserProfile() async let recentArticles = fetchRecentArticles() async let notifications = fetchNotifications() // Wait for all results together return Dashboard( user: try await userProfile, articles: try await recentArticles, notifications: try await notifications )}
The actual async work starts when you await the async let binding, and all three begin concurrently.
TaskGroup — Dynamic Parallel Processing
When the number of parallel tasks isn't known at compile time, use TaskGroup:
func fetchAllAvatars(userIds: [String]) async throws -> [String: UIImage] { try await withThrowingTaskGroup(of: (String, UIImage).self) { group in for userId in userIds { group.addTask { let image = try await fetchAvatar(userId: userId) return (userId, image) } } var avatars: [String: UIImage] = [:] for try await (userId, image) in group { avatars[userId] = image } return avatars }}
When you need to throttle concurrency (say, max 5 simultaneous requests), Antigravity can generate the pattern automatically:
// Concurrency-limited TaskGroupfunc fetchWithConcurrencyLimit<T>( items: [T], maxConcurrency: Int = 5, operation: @Sendable @escaping (T) async throws -> Void) async throws { try await withThrowingTaskGroup(of: Void.self) { group in var activeTasks = 0 var iterator = items.makeIterator() // Seed the initial batch while activeTasks < maxConcurrency, let item = iterator.next() { group.addTask { try await operation(item) } activeTasks += 1 } // Refill as tasks complete for try await _ in group { activeTasks -= 1 if let item = iterator.next() { group.addTask { try await operation(item) } activeTasks += 1 } } }}
AsyncStream and AsyncSequence — Real-Time Data Processing
AsyncStream Fundamentals
AsyncStream is the Swift Concurrency-native equivalent of Combine's Publisher. It's ideal for streaming data sources: WebSocket messages, location updates, sensor readings, and any other time-series data.
// Receive WebSocket messages as an AsyncStreamclass WebSocketManager { private var webSocket: URLSessionWebSocketTask? func messageStream(url: URL) -> AsyncStream<String> { AsyncStream { continuation in let session = URLSession(configuration: .default) let webSocket = session.webSocketTask(with: url) self.webSocket = webSocket Task { webSocket.resume() while !Task.isCancelled { do { let message = try await webSocket.receive() switch message { case .string(let text): continuation.yield(text) case .data(let data): if let text = String(data: data, encoding: .utf8) { continuation.yield(text) } @unknown default: break } } catch { continuation.finish() break } } } continuation.onTermination = { _ in webSocket.cancel() } } }}// Usagelet manager = WebSocketManager()for await message in manager.messageStream(url: wsURL) { print("Received: \(message)")}
Bridging Delegate-Based APIs to AsyncStream
A common pattern is wrapping delegate-based APIs (like CLLocationManager) in an AsyncStream:
extension CLLocationManager { var locationStream: AsyncStream<CLLocation> { AsyncStream { continuation in let delegate = LocationDelegate(continuation: continuation) self.delegate = delegate self.startUpdatingLocation() continuation.onTermination = { [weak self] _ in self?.stopUpdatingLocation() } } }}private class LocationDelegate: NSObject, CLLocationManagerDelegate { let continuation: AsyncStream<CLLocation>.Continuation init(continuation: AsyncStream<CLLocation>.Continuation) { self.continuation = continuation } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { for location in locations { continuation.yield(location) } } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { continuation.finish() }}// Usagelet locationManager = CLLocationManager()for await location in locationManager.locationStream { print("Current location: \(location.coordinate)")}
Just tell Antigravity "wrap CLLocationManager for Swift Concurrency" and it generates this bridge pattern automatically.
@Sendable and Data Race Safety
Understanding @Sendable
@Sendable tells the compiler that a closure or type can safely cross concurrency domain boundaries:
// @Sendable closure — must be thread-safefunc processInBackground<T: Sendable>( value: T, transform: @Sendable @escaping (T) async throws -> T) async throws -> T { try await Task.detached(priority: .background) { try await transform(value) }.value}// Value types automatically conform to Sendablestruct ArticleMetadata: Sendable { let id: String let title: String let publishedAt: Date}// Classes can be Sendable if all stored properties are constant and Sendablefinal class ImmutableConfig: Sendable { let apiEndpoint: URL let apiKey: String init(apiEndpoint: URL, apiKey: String) { self.apiEndpoint = apiEndpoint self.apiKey = apiKey }}
Migrating to Swift 6 (Strict Concurrency Checking)
Swift 6 makes data races compile errors. Use the SWIFT_STRICT_CONCURRENCY build setting to migrate incrementally:
// In Package.swift.target( name: "MyApp", swiftSettings: [ .swiftLanguageMode(.v6) ])
Tell Antigravity "migrate this code to Swift 6 strict concurrency" and it will add @Sendable annotations, apply nonisolated where appropriate, and flag remaining issues for manual review.
Testing Async Code
Async XCTest Methods
import XCTest@testable import MyAppfinal class ArticleServiceTests: XCTestCase { var sut: ArticleService! var mockRepository: MockArticleRepository! @MainActor override func setUp() async throws { try await super.setUp() mockRepository = MockArticleRepository() sut = ArticleService(repository: mockRepository) } func testFetchArticlesSuccess() async throws { // Given let expectedArticles = [ Article(id: "1", title: "Test Article 1"), Article(id: "2", title: "Test Article 2") ] mockRepository.articlesToReturn = expectedArticles // When let articles = try await sut.fetchArticles() // Then XCTAssertEqual(articles.count, 2) XCTAssertEqual(articles[0].title, "Test Article 1") } func testFetchArticlesTimeout() async throws { mockRepository.simulateDelay = true do { _ = try await withTimeout(seconds: 1.0) { try await self.sut.fetchArticles() } XCTFail("Expected a timeout error") } catch is TimeoutError { // Expected behavior } }}// Timeout helperfunc withTimeout<T: Sendable>( seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw TimeoutError() } guard let result = try await group.next() else { throw TimeoutError() } group.cancelAll() return result }}struct TimeoutError: Error {}
Testing Actors
Testing actors is straightforward — just await method calls as usual:
final class ImageCacheTests: XCTestCase { func testCacheStoreAndRetrieve() async { let cache = ImageCache() let testURL = URL(string: "https://example.com/image.png")! let testImage = UIImage(systemName: "star.fill")! await cache.store(testImage, for: testURL) let retrieved = await cache.image(for: testURL) XCTAssertNotNil(retrieved) }}
Common Errors and How to Fix Them
Error ① "Expression is 'async' but is not marked with 'await'"
You called an async function without await:
// ❌ Errorlet user = fetchUser(id: "123")// ✅ Fixedlet user = await fetchUser(id: "123")// or, if it also throws:let user = try await fetchUser(id: "123")
Error ② "Sending 'x' risks causing data races" (Swift 6)
You're trying to pass a non-Sendable value across an actor boundary:
// ❌ NSMutableArray is not Sendableactor DataStore { var items: NSMutableArray = []}// ✅ Use Sendable value typesactor DataStore { var items: [String] = [] // Array<String> conforms to Sendable}
Error ③ Task Leaks
Tasks that aren't properly managed can outlive their parent objects:
// ❌ This Task keeps running even after ViewModel is deallocatedclass ViewModel { func startPolling() { Task { while true { await refresh() try await Task.sleep(nanoseconds: 5_000_000_000) } } }}// ✅ Hold a reference and cancel on cleanupclass ViewModel { private var pollingTask: Task<Void, Never>? func startPolling() { pollingTask = Task { while !Task.isCancelled { await refresh() try? await Task.sleep(nanoseconds: 5_000_000_000) } } } func stopPolling() { pollingTask?.cancel() pollingTask = nil } deinit { pollingTask?.cancel() }}
Production Patterns — Putting It All Together
Repository Pattern with Swift Concurrency
protocol ArticleRepositoryProtocol: Sendable { func fetchAll() async throws -> [Article] func fetchById(_ id: String) async throws -> Article func save(_ article: Article) async throws}actor ArticleRepository: ArticleRepositoryProtocol { private let apiClient: APIClient private let localStorage: LocalStorageProtocol private var cache: [String: Article] = [:] init(apiClient: APIClient, localStorage: LocalStorageProtocol) { self.apiClient = apiClient self.localStorage = localStorage } func fetchAll() async throws -> [Article] { // Try local storage first let localArticles = try await localStorage.fetchArticles() if !localArticles.isEmpty { return localArticles } // Fall back to API let remoteArticles = try await apiClient.get("/articles", responseType: [Article].self) try await localStorage.saveArticles(remoteArticles) return remoteArticles } func fetchById(_ id: String) async throws -> Article { if let cached = cache[id] { return cached } let article = try await apiClient.get("/articles/\(id)", responseType: Article.self) cache[id] = article return article } func save(_ article: Article) async throws { try await apiClient.post("/articles", body: article) cache[article.id] = article try await localStorage.saveArticle(article) }}
Retry Logic with Exponential Backoff
func withRetry<T>( maxAttempts: Int = 3, delay: Duration = .seconds(1), operation: @escaping () async throws -> T) async throws -> T { var lastError: Error? for attempt in 0..<maxAttempts { do { return try await operation() } catch { lastError = error // Don't retry auth errors if let appError = error as? AppError, case .unauthorized = appError { throw error } if attempt < maxAttempts - 1 { try await Task.sleep(for: delay * Double(attempt + 1)) } } } throw lastError!}
Real-World Integration: Building a Complete Async Data Layer
Let's bring everything together with a complete, production-ready example. This pattern integrates all the Swift Concurrency concepts covered in this guide into a cohesive architecture that Antigravity can generate and maintain for you.
The Complete NewsArticle App Architecture
// MARK: - Domain Models (Sendable value types)struct NewsArticle: Sendable, Identifiable, Codable { let id: String let title: String let summary: String let category: String let publishedAt: Date var isBookmarked: Bool}// MARK: - Network Layeractor NewsAPIClient { private let session: URLSession private let baseURL: URL private let decoder: JSONDecoder init(baseURL: URL) { self.baseURL = baseURL self.session = URLSession(configuration: .default) self.decoder = { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 return d }() } func fetchArticles(category: String? = nil, page: Int = 1) async throws -> [NewsArticle] { var components = URLComponents(url: baseURL.appendingPathComponent("/articles"), resolvingAgainstBaseURL: false)! var queryItems: [URLQueryItem] = [URLQueryItem(name: "page", value: "\(page)")] if let category = category { queryItems.append(URLQueryItem(name: "category", value: category)) } components.queryItems = queryItems let (data, response) = try await session.data(from: components.url!) guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { throw APIError.serverError } return try decoder.decode([NewsArticle].self, from: data) }}// MARK: - Local Persistence (using actor for thread safety)actor ArticleStore { private var articles: [String: NewsArticle] = [:] private var bookmarks: Set<String> = [] func upsert(_ articles: [NewsArticle]) { for article in articles { self.articles[article.id] = article } } func allArticles() -> [NewsArticle] { Array(articles.values).sorted { $0.publishedAt > $1.publishedAt } } func toggleBookmark(articleId: String) -> Bool { if bookmarks.contains(articleId) { bookmarks.remove(articleId) return false } else { bookmarks.insert(articleId) return true } } func isBookmarked(articleId: String) -> Bool { bookmarks.contains(articleId) }}// MARK: - ViewModel with @MainActor@MainActorclass NewsViewModel: ObservableObject { @Published var articles: [NewsArticle] = [] @Published var isLoading = false @Published var errorMessage: String? @Published var selectedCategory: String? = nil private let apiClient: NewsAPIClient private let store: ArticleStore private var refreshTask: Task<Void, Never>? init(apiClient: NewsAPIClient, store: ArticleStore) { self.apiClient = apiClient self.store = store } func loadArticles(refreshing: Bool = false) async { guard !isLoading else { return } isLoading = true errorMessage = nil do { let fetched = try await withRetry(maxAttempts: 3) { try await self.apiClient.fetchArticles(category: self.selectedCategory) } await store.upsert(fetched) let enriched = await enrichWithBookmarks(fetched) articles = enriched } catch { errorMessage = "Failed to load articles. Please try again." // Fall back to cached articles articles = await store.allArticles() } isLoading = false } func toggleBookmark(for article: NewsArticle) async { let newValue = await store.toggleBookmark(articleId: article.id) // Update the specific article in the list if let index = articles.firstIndex(where: { $0.id == article.id }) { articles[index].isBookmarked = newValue } } func startAutoRefresh(interval: Duration = .seconds(60)) { stopAutoRefresh() refreshTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: interval) guard !Task.isCancelled else { break } await self?.loadArticles(refreshing: true) } } } func stopAutoRefresh() { refreshTask?.cancel() refreshTask = nil } private func enrichWithBookmarks(_ articles: [NewsArticle]) async -> [NewsArticle] { var enriched = articles for i in enriched.indices { enriched[i].isBookmarked = await store.isBookmarked(articleId: enriched[i].id) } return enriched } deinit { refreshTask?.cancel() }}enum APIError: Error { case serverError case noData}
This architecture demonstrates the complete picture:
NewsArticle as a Sendable value type that crosses actor boundaries safely
NewsAPIClient as an actor managing URLSession concurrency
ArticleStore as an actor protecting local state from data races
NewsViewModel on @MainActor ensuring all UI updates happen on the main thread
Auto-refresh Task that's properly cancelled on cleanup
When you share this architecture description with Antigravity, it can scaffold the entire data layer, write corresponding unit tests, and even suggest SwiftUI views to go with it.
Handling Pagination with AsyncSequence
For infinite-scroll pagination, you can model the page stream as a custom AsyncSequence:
struct ArticlePageSequence: AsyncSequence { typealias Element = [NewsArticle] let apiClient: NewsAPIClient let category: String? struct AsyncIterator: AsyncIteratorProtocol { let apiClient: NewsAPIClient let category: String? var currentPage = 1 var hasMore = true mutating func next() async throws -> [NewsArticle]? { guard hasMore else { return nil } let articles = try await apiClient.fetchArticles( category: category, page: currentPage ) if articles.isEmpty { hasMore = false return nil } currentPage += 1 return articles } } func makeAsyncIterator() -> AsyncIterator { AsyncIterator(apiClient: apiClient, category: category) }}// Usage in ViewModelfunc loadAllArticles() async throws { var allArticles: [NewsArticle] = [] let pageStream = ArticlePageSequence(apiClient: apiClient, category: selectedCategory) for try await page in pageStream { allArticles.append(contentsOf: page) // Update UI progressively as pages arrive await MainActor.run { self.articles = allArticles } }}
This pattern elegantly separates pagination logic from the ViewModel, making each component independently testable. Ask Antigravity to "create a paginated AsyncSequence for my API client" and it will generate this pattern tailored to your existing code structure.
Adopting Swift Concurrency Incrementally
Not every project can switch to Swift Concurrency overnight. Here's a pragmatic migration strategy:
Phase 1 — Add async/await at the boundaries. Start by converting your highest-level async calls (ViewModel → Repository) to async/await. Keep internals as-is temporarily. This delivers immediate readability gains with minimal risk.
Phase 2 — Introduce actors for shared state. Identify any classes with locks, serial dispatch queues, or @synchronized patterns. These are prime candidates for conversion to actor. One at a time, guided by Antigravity.
Phase 3 — Replace Combine publishers with AsyncSequence. Convert PassthroughSubject/CurrentValueSubject patterns to AsyncStream where it makes the code cleaner. Use .values property to consume existing Combine publishers as async sequences during the transition.
Phase 4 — Enable Strict Concurrency Checking. Set SWIFT_STRICT_CONCURRENCY = targeted in build settings. Address the warnings, then bump to complete and tackle any remaining issues. Antigravity's code review mode excels at catching and explaining @Sendable violations.
This phased approach lets you ship value at each stage without rewriting your entire app at once.
The Actor Reentrancy Trap You Won't Find in the Docs
Let me share something the documentation rarely spells out — a bug I hit when I first put Swift Concurrency into a shipping app. I've built apps solo since 2014, and I still run a lineup of wallpaper and relaxation apps. During a routine update, I started leaning on actor for shared state, and a subtle bug cost me a few days.
Actors are described as "preventing data races." That's true, but the part that gets lost is this: the moment you await inside an actor method, that actor becomes free to service other calls (this is reentrancy). The code below looks safe and quietly corrupts state.
// ❌ Reentrancy causes duplicate downloadsactor ThumbnailCache { private var cache: [URL: UIImage] = [:] func image(for url: URL) async throws -> UIImage { if let cached = cache[url] { return cached } // While we await here, another call for the same url can slip in let (data, _) = try await URLSession.shared.data(from: url) let image = UIImage(data: data)! cache[url] = image return image }}
When the same URL is requested several times in quick succession, the second and third calls pass the cache[url] nil-check before the first await resolves, so the same image gets downloaded repeatedly. On a wallpaper grid where thumbnails load all at once, this visibly wasted bandwidth and memory.
The fix is to cache the in-flight task, not the image.
// ✅ Share the in-flight task to deduplicate workactor ThumbnailCache { private enum Entry { case inProgress(Task<UIImage, Error>) case ready(UIImage) } private var entries: [URL: Entry] = [:] func image(for url: URL) async throws -> UIImage { if let entry = entries[url] { switch entry { case .ready(let image): return image case .inProgress(let task): return try await task.value // ride along on the existing download } } let task = Task<UIImage, Error> { let (data, _) = try await URLSession.shared.data(from: url) guard let image = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) } return image } entries[url] = .inProgress(task) do { let image = try await task.value entries[url] = .ready(image) return image } catch { entries[url] = nil // clear on failure so the next call can retry throw error } }}
The key is that storing the Task into entries happens synchronously — there is no suspension point between the start of the method and entries[url] = .inProgress(task), so no reentrant call can wedge itself in there. Asking Antigravity "can this actor cache double-fetch under reentrancy?" gets you this ride-along pattern with a concrete example, which made it a useful design-review partner.
What the Swift 6 Migration Actually Measured
"Migrating to Swift Concurrency makes things faster and safer" usually stays abstract, so here are real numbers from one of my own apps. The case is the settings screen, which on launch hits three independent APIs (remote config, subscription status, announcements).
Before the migration, completion handlers ran these serially, averaging 1,180ms on a physical device (iPhone 12, 4G). Switching to parallel async let brought the average down to 420ms — about a 2.8x reduction. Because the three calls were independent, the total collapsed to roughly the slowest single request.
The bigger win was crash rate. Before migrating, Crashlytics intermittently logged EXC_BAD_ACCESS crashes that looked like several threads touching the same array. They were rare, I could never reproduce them, and I'd left them sitting for over half a year. After confining the mutable state inside an actor and pushing SWIFT_STRICT_CONCURRENCY to complete so the compiler could close the holes, that family of crashes dropped to essentially zero from the next release on. Data races produce "not crashing but already broken" states, so letting the compiler catch them is worth more than the raw numbers suggest.
Don't flip everything at once. The order I recommend:
Keep SWIFT_STRICT_CONCURRENCY at minimal and mechanically convert completion handlers to async/await first.
Move only your mutable cache/store layers into actors.
Bump the build setting to targeted and address each warning with @Sendable or nonisolated.
Finally raise it to complete (or the Swift 6 language mode) and clear the remaining warnings.
Jumping straight to complete floods you with hundreds of warnings at once, which is demoralizing. I gave up the first time I tried it that way, so taking the longer route through targeted is, in my experience, actually faster.
Summary
Swift Concurrency represents the future of iOS async programming. async/await dramatically improves readability, Actor eliminates data races at compile time, and Structured Concurrency keeps task lifecycles clean and predictable.
Antigravity accelerates your journey through this landscape — generating boilerplate on demand, guiding migrations from legacy patterns, and helping debug the subtle concurrency issues that even experienced developers struggle with. Start today by converting a single completion handler in your codebase. Antigravity will handle the heavy lifting.
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.