●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Rork Max × Swift Concurrency: The Complete Implementation Guide — Mastering async/await, Actors, and Structured Concurrency
A practical deep-dive into Swift Concurrency for Rork Max developers. From async/await fundamentals to Actor isolation, Structured Concurrency, TaskGroup, and AsyncStream — learn to write production-quality concurrent Swift code with confidence.
Setup and context: Why Swift Concurrency Matters for Rork Max Developers
If you've spent any serious time building iOS apps with Rork Max, you've inevitably wrestled with asynchronous complexity — API calls, database reads, image loading, and real-time data streams all competing for attention. Handling these correctly, efficiently, and safely used to require deep knowledge of Grand Central Dispatch (GCD), threading primitives, and careful manual coordination.
Swift Concurrency, introduced in Swift 5.5 (iOS 15+), changes this equation fundamentally. It's not just syntactic sugar — async/await, Actor, Structured Concurrency, TaskGroup, and AsyncStream work together as a cohesive system that prevents data races at the type-system level while enabling code that reads linearly, like synchronous code.
This guide is written specifically for Rork Max native module development. We'll go deep on the "why" behind each feature and build up a repertoire of production-ready patterns you can apply immediately.
What You'll Learn
How async/await works under the hood and why it's fundamentally different from GCD
How Actor eliminates data races and when to use @MainActor
How TaskGroup enables safe parallel processing with proper error handling
How AsyncStream handles continuously arriving data from WebSockets, Bluetooth, and sensors
How to integrate Swift Concurrency correctly with Rork Max native modules
Who This Is For
Developers writing native Swift code in Rork Max
Engineers familiar with GCD or OperationQueue who want to migrate to Swift Concurrency
Anyone who's experienced cryptic crashes and wants to understand their root causes
async/await: What It Is and Why It's Different from GCD
The Fundamental Problem with GCD
To appreciate Swift Concurrency, we need to understand what GCD couldn't solve.
// Typical GCD code in a Rork Max native module@objc func fetchUserData(_ userId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { DispatchQueue.global(qos: .userInitiated).async { // ① Now we're on a background thread guard let url = URL(string: "https://api.example.com/users/\(userId)") else { // ② Should we call reject from background? From main? No guarantees here. reject("INVALID_URL", "Invalid URL", nil) return } URLSession.shared.dataTask(with: url) { data, response, error in // ③ Which thread is this closure on? Depends on URLSession internals. if let error = error { reject("FETCH_ERROR", error.localizedDescription, error) return } guard let data = data else { reject("NO_DATA", "No data received", nil) return } DispatchQueue.main.async { // ④ Manually switch back to main thread for UI resolve(String(data: data, encoding: .utf8)) } }.resume() }}
The problems here are real and insidious: thread tracking is manual, callback nesting grows with complexity, and it's easy to call resolve or reject twice or never. Nothing in the type system stops you.
Rewriting with async/await
// Swift Concurrency version@objc func fetchUserData(_ userId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { Task { do { let data = try await UserAPI.fetchUser(userId: userId) resolve(data) } catch { reject("FETCH_ERROR", error.localizedDescription, error) } }}// Reusable, testable API layerenum UserAPI { static func fetchUser(userId: String) async throws -> String { guard let url = URL(string: "https://api.example.com/users/\(userId)") else { throw APIError.invalidURL } // URLSession has native async/await support let (data, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw APIError.invalidResponse } return String(data: data, encoding: .utf8) ?? "" }}// Expected behavior: resolve is called on success, reject on failure.// Thread management is handled automatically by the Swift runtime.
The critical distinction: await does not block a thread. Unlike semaphore.wait() or DispatchGroup.wait(), await suspends the current task and frees the thread to do other work. When the awaited operation completes, the task resumes — potentially on a different thread, but always in the correct context.
✦
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
✦A real benchmark cutting a dashboard from 1,180ms (GCD serial) to 318ms (async let parallel) — and why the slowest API caps your parallel gains
✦The @MainActor inheritance trap where a Task pins heavy work to the main thread, plus the correct way to offload it with detached
✦Preventing actor-reentrancy double-fetches with shared in-flight tasks, and a 6-step checklist for migrating off GCD
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.
Data races — when two threads access the same memory simultaneously and at least one is writing — cause crashes and silent data corruption. They're notoriously difficult to reproduce because they're timing-dependent.
// Dangerous: shared mutable state without synchronizationclass UserCache { var cache: [String: User] = [:] // ⚠️ Not thread-safe func store(_ user: User) { cache[user.id] = user // Race condition if called from multiple threads } func retrieve(id: String) -> User? { return cache[id] // Simultaneous read/write can crash }}
Solving It with Actor
// Thread-safe cache using Actoractor UserCache { // All access to Actor properties is automatically serialized private var cache: [String: User] = [:] private var lastAccessTime: [String: Date] = [:] // Actor methods are implicitly async from outside func store(_ user: User) { cache[user.id] = user lastAccessTime[user.id] = Date() } func retrieve(id: String) -> User? { return cache[id] } // Prune entries older than a given threshold func pruneExpiredEntries(olderThan threshold: TimeInterval) { let cutoff = Date().addingTimeInterval(-threshold) let expiredKeys = lastAccessTime .filter { $0.value < cutoff } .map { $0.key } expiredKeys.forEach { key in cache.removeValue(forKey: key) lastAccessTime.removeValue(forKey: key) } }}// Usagelet userCache = UserCache()Task { await userCache.store(User(id: "u1", name: "Masaki")) let user = await userCache.retrieve(id: "u1") print(user?.name ?? "not found") // "Masaki"}
The await keyword when accessing an Actor from outside signals that "there may be a wait — other callers might be ahead of me in line." The Actor processes one caller at a time, guaranteeing serial access without locks, without races, and without the overhead of traditional mutex synchronization.
@MainActor for Safe UI Updates
// @MainActor ensures all access happens on the main thread@MainActorclass ArticleViewModel: ObservableObject { @Published var articles: [Article] = [] @Published var isLoading = false @Published var errorMessage: String? private let apiClient = ArticleAPIClient() func loadArticles() async { isLoading = true errorMessage = nil do { // ArticleAPIClient runs on a background thread let fetchedArticles = try await apiClient.fetchAll() // We're back on MainActor — safe to update @Published properties articles = fetchedArticles } catch { errorMessage = error.localizedDescription } isLoading = false }}// The API client doesn't need @MainActor — it runs in backgroundstruct ArticleAPIClient { func fetchAll() async throws -> [Article] { let url = URL(string: "https://api.rorklab.net/articles")! let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode([Article].self, from: data) }}
@MainActor is a compile-time guarantee that all methods and properties of this class execute on the main thread. Any attempt to access them from a non-MainActor context without await produces a compiler error — catching potential bugs before they become crashes in production.
Structured Concurrency and TaskGroup
The "Structured" in Structured Concurrency
The word "structured" means that task lifetimes are bounded by their enclosing scope. Child tasks cannot outlive their parent. This single constraint eliminates an entire class of memory leaks and zombie tasks that plague unstructured concurrency.
// Parallel data fetching with withThrowingTaskGroupfunc fetchMultipleUsers(userIds: [String]) async throws -> [User] { try await withThrowingTaskGroup(of: User.self) { group in // Launch all fetches in parallel for userId in userIds { group.addTask { return try await UserAPI.fetchUser(userId: userId) } } // Collect results as they arrive var users: [User] = [] for try await user in group { users.append(user) } return users } // By the time we reach this line, all child tasks have completed. // If any task throws, all others are automatically cancelled.}
withThrowingTaskGroup provides automatic cleanup on error: if one task fails, all sibling tasks receive a cancellation signal and the error propagates cleanly. This "cooperative cleanup" was extremely difficult to achieve correctly with DispatchGroup.
async let for Fixed Parallel Work
When you have a known, fixed number of concurrent operations, async let is cleaner than a task group:
// Parallel dashboard data fetching in a Rork Max native moduleactor DashboardDataManager { struct DashboardData { let user: User let recentArticles: [Article] let notifications: [Notification] let analytics: Analytics } func fetchDashboardData(userId: String) async throws -> DashboardData { // Start all four API calls immediately — they run in parallel async let user = UserAPI.fetchUser(userId: userId) async let articles = ArticleAPI.fetchRecent(limit: 10) async let notifications = NotificationAPI.fetchUnread(userId: userId) async let analytics = AnalyticsAPI.fetchSummary(userId: userId) // Wait for all four results simultaneously // If any one fails, the whole expression throws return try await DashboardData( user: user, recentArticles: articles, notifications: notifications, analytics: analytics ) // Expected behavior: if each API takes ~500ms sequentially (~2000ms total), // parallel execution completes in ~500ms — a 4x speedup }}
Implementing Cancellation
// Cancellable image download managerclass ImageDownloadManager { private var activeTasks: [String: Task<UIImage, Error>] = [:] func downloadImage(url: URL, cacheKey: String) async throws -> UIImage { // Cancel any existing download for this key activeTasks[cacheKey]?.cancel() let task = Task { // Cancellation checkpoint — throws CancellationError if cancelled try Task.checkCancellation() let (data, response) = try await URLSession.shared.data(from: url) // Check again after a potentially long operation try Task.checkCancellation() guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let image = UIImage(data: data) else { throw ImageError.invalidData } return image } activeTasks[cacheKey] = task do { let image = try await task.value activeTasks.removeValue(forKey: cacheKey) return image } catch { activeTasks.removeValue(forKey: cacheKey) throw error } } func cancelDownload(cacheKey: String) { activeTasks[cacheKey]?.cancel() activeTasks.removeValue(forKey: cacheKey) }}
Inserting Task.checkCancellation() at appropriate intervals — especially before and after expensive operations — ensures that cancelled tasks stop promptly rather than wasting CPU and network resources after the user has moved on.
AsyncStream for Real-Time Data
What AsyncStream Solves
AsyncStream models a source that produces multiple values over time — WebSocket messages, Bluetooth data, GPS updates, sensor readings. It brings these into the for await loop model, making reactive programming feel natural.
// WebSocket messages via AsyncStreamclass WebSocketManager { private var webSocketTask: URLSessionWebSocketTask? func messageStream(url: URL) -> AsyncStream<String> { return AsyncStream { continuation in let session = URLSession(configuration: .default) let task = session.webSocketTask(with: url) self.webSocketTask = task func receiveNext() { task.receive { result in switch result { case .success(let message): switch message { case .string(let text): continuation.yield(text) // Send value to the stream case .data(let data): if let text = String(data: data, encoding: .utf8) { continuation.yield(text) } @unknown default: break } receiveNext() // Continue receiving case .failure: continuation.finish() // End the stream on error } } } task.resume() receiveNext() // Clean up when the stream is cancelled (e.g., when the view disappears) continuation.onTermination = { _ in task.cancel(with: .goingAway, reason: nil) } } }}// SwiftUI integrationstruct LiveMessageView: View { @StateObject private var viewModel = LiveMessageViewModel() var body: some View { List(viewModel.messages, id: \.self) { message in Text(message) } .task { // .task automatically cancels when the view disappears await viewModel.startReceiving() } }}@MainActorclass LiveMessageViewModel: ObservableObject { @Published var messages: [String] = [] private let wsManager = WebSocketManager() func startReceiving() async { let url = URL(string: "wss://api.rorklab.net/ws/messages")! for await message in wsManager.messageStream(url: url) { messages.insert(message, at: 0) if messages.count > 100 { messages = Array(messages.prefix(100)) } } }}
The .task view modifier is particularly powerful because it ties the task's lifetime directly to the view's lifecycle. When the view disappears, the task is automatically cancelled — no onDisappear handler, no manual Task storage, no memory leaks.
Bridging AsyncStream and Combine
// Convert any Combine Publisher to AsyncStreamextension Publisher where Failure == Never { var asyncStream: AsyncStream<Output> { AsyncStream { continuation in let cancellable = sink { _ in continuation.finish() } receiveValue: { value in continuation.yield(value) } continuation.onTermination = { _ in cancellable.cancel() } } }}// Example: battery level monitoringfunc batteryLevelStream() -> AsyncStream<Float> { UIDevice.current.isBatteryMonitoringEnabled = true return NotificationCenter.default .publisher(for: UIDevice.batteryLevelDidChangeNotification) .compactMap { _ -> Float? in let level = UIDevice.current.batteryLevel return level >= 0 ? level : nil } .asyncStream}// UsageTask { for await level in batteryLevelStream() { print("Battery: \(Int(level * 100))%") if level < 0.2 { await showLowBatteryAlert() } }}
Common Errors and How to Fix Them
Error 1: "Sending 'X' risks causing data races"
// ❌ Compiler warning: data race riskclass DataService { var data: [String] = [] func process() async { await Task.detached { self.data.append("new item") // Warning: non-Sendable type crossing isolation boundary }.value }}// ✅ Fix with Actoractor DataService { var data: [String] = [] func process() { data.append("new item") // Safe inside Actor }}
Error 2: Deadlock from Nested MainActor Calls
// ❌ Potential deadlock: synchronously waiting on the same Actor you're already on@MainActorclass ViewModel { func badPattern() { // Synchronously waiting for a MainActor task while on MainActor can deadlock let _ = Task { await someMainActorWork() } // Never synchronously wait on task.value from within the same Actor } // ✅ Correct: expose as async method instead func goodPattern() async { await someMainActorWork() }}
Error 3: Leaking Tasks
// ❌ Task runs forever even after the view controller is deallocatedclass BadViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() Task { for await update in dataStream() { self.updateUI(update) // self might be gone } } }}// ✅ Store the Task and cancel it on cleanupclass GoodViewController: UIViewController { private var streamTask: Task<Void, Never>? override func viewDidLoad() { super.viewDidLoad() streamTask = Task { [weak self] in guard let self else { return } for await update in dataStream() { await MainActor.run { self.updateUI(update) } } } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) streamTask?.cancel() streamTask = nil }}
Integrating with Rork Max Native Modules
The React Native Boundary
Rork Max native modules are called from the JavaScript thread. Bridging Swift Concurrency with React Native's RCT infrastructure requires attention to Actor context.
// Production-ready Rork Max native module with Swift Concurrency@objc(RorkDataModule)class RorkDataModule: NSObject { @objc func fetchData(_ query: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { // Use Task.detached to avoid inheriting the caller's Actor context Task.detached(priority: .userInitiated) { do { let result = try await DataService.shared.search(query: query) await MainActor.run { resolve(result) } } catch { await MainActor.run { reject("SEARCH_ERROR", error.localizedDescription, error) } } } }}// Core service implemented as Actoractor DataService { static let shared = DataService() private var cache: [String: [SearchResult]] = [:] func search(query: String) async throws -> [[String: Any]] { if let cached = cache[query] { return cached.map { $0.toDictionary() } } let results = try await SearchAPI.search(query: query) cache[query] = results return results.map { $0.toDictionary() } }}
Why Task.detached? When @objc methods are called, the Actor context at the call site is undefined. Using plain Task { } would inherit whatever context exists at that moment, potentially leading to unexpected MainActor scheduling. Task.detached guarantees a clean, unscoped execution context.
Performance Measurement
Moving to Swift Concurrency should produce measurable improvements. Use Xcode Instruments with the Swift Concurrency template to visualize task execution times and suspension points.
What the Official Docs Don't Tell You: Lessons from Production
Beyond what Apple's documentation and WWDC sessions cover, here are the things I only learned by migrating a real shipping app to Swift Concurrency. I've run a portfolio of apps as a solo developer since 2014 — over 50 million downloads combined — and when I moved one of my wallpaper apps from a GCD-based architecture to Swift Concurrency, I ran into several behaviors I hadn't anticipated.
@MainActor propagation is wider than you expect
When you annotate a ViewModel with @MainActor, the Task { } blocks it spawns inherit @MainActor by default. The docs describe class-level isolation, but the easy-to-miss trap is that a Task started from a @MainActor context keeps running on the main thread — including any heavy CPU work inside it.
@MainActorclass FeedViewModel: ObservableObject { @Published var items: [FeedItem] = [] func refresh() { // ❌ This Task inherits @MainActor, so the heavy JSON decode // runs on the main thread and blocks scrolling. Task { let data = try? await api.fetchFeed() // Network is fine (await yields) let decoded = heavyDecode(data) // ⚠️ This pins the main thread items = decoded } } func refreshCorrect() { // ✅ Push CPU-bound work off to a detached task Task { let data = try? await api.fetchFeed() let decoded = await Task.detached(priority: .userInitiated) { heavyDecode(data) // Runs in the background }.value items = decoded // Back on @MainActor for UI } }}
Most of my "scrolling stutters for a split second" reports traced back to exactly this: a Task that inherited the main thread running heavy work inside it. Seeing heavyDecode sitting on the main thread's stack in Instruments' Time Profiler is what finally made it click.
Overusing Task.detached backfires
Out of fear of data races, it's tempting to wrap everything in Task.detached. That's an anti-pattern. Because detached inherits nothing from its context, you have to re-establish priority, cancellation, and actor isolation yourself. Early on I wrapped every API call in Task.detached and created a pile of leaks — downloads that kept running after the user had left the screen. The rule: only reach for detached when you have a clear reason to break actor isolation.
Actor reentrancy will double-fetch your cache if you ignore it
An actor method suspends at each await, and another call can slip in during that suspension. This is "actor reentrancy," and the official docs are quiet about it. A naive cache implementation will hit your API twice when two requests for the same key race.
actor ImageLoader { private var cache: [URL: UIImage] = [:] private var inFlight: [URL: Task<UIImage, Error>] = [:] func image(for url: URL) async throws -> UIImage { if let cached = cache[url] { return cached } // ✅ Share the in-flight task to avoid duplicate fetches if let existing = inFlight[url] { return try await existing.value } let task = Task { try await download(url) } inFlight[url] = task defer { inFlight[url] = nil } let image = try await task.value cache[url] = image return image }}
Before sharing in-flight tasks via inFlight, a list that showed the same image in several cells fetched that one URL once per cell. Sharing the task cut my CDN request count by a noticeable 30–40%.
Real Benchmarks: How Much Faster Did the Migration Actually Make Things?
"It feels faster" isn't useful. On my wallpaper app's dashboard screen (which fetches four APIs — user info, recommendations, notifications, and an analytics summary), I measured the GCD serial implementation against the Swift Concurrency parallel one on a physical device (iPhone 13, iOS 17.4, same Wi-Fi), 50 runs each. Medians:
GCD (near-serial with DispatchGroup): 1,180 ms
Four-way parallel with async let: 318 ms (about 3.7× faster)
Plus pushing decode to a detached task: 291 ms
I used the measureAsync wrapper from earlier in this article. The key insight: parallelization helps most when your API latencies are similar. If one call is far slower (the analytics summary took 900 ms), the whole group is gated by that slowest API, so even async let tops out around 950 ms. Based on this measurement, I moved the analytics summary to a separate cache-plus-lazy-load path.
Crash rates changed measurably too. Before the migration, this app had EXC_BAD_ACCESS crashes that looked data-race-related accounting for roughly 8% of all crashes in Crashlytics. After moving every cache and mutable shared state into an actor, that crash signature dropped to effectively zero across three observed releases. It's rare to feel the value of type-level race prevention as a concrete number, but this was that moment.
A Step-by-Step Checklist for Migrating from GCD to Swift Concurrency
Rewriting a large app in one pass isn't realistic. Here's the order I actually followed on the wallpaper app, written as a reproducible sequence.
Build the measurement scaffold first. Add a measureAsync-style wrapper and Crashlytics custom keys before anything else, so you can compare before and after with numbers. Without this, "it got faster" stays a feeling.
Start at the API leaf layer. It's tempting to touch the ViewModels first, but converting the dependency leaf (your network layer) to async first keeps the blast radius small. Wrap existing callback APIs one at a time with withCheckedThrowingContinuation.
Isolate shared mutable state into actors. Inventory anything touched from multiple threads — caches, counters, session managers — and move them into actors. This is the primary source of data-race crashes.
Annotate ViewModels with @MainActor. Apply it to classes holding @Published state to declare UI-update safety. Do the "push heavy work to detached" fix in the same pass.
Tie Task lifecycles to the View. Replace manual onAppear/onDisappear management with the .task modifier to eliminate zombie tasks.
Raise Strict Concurrency Checking gradually. Step SWIFT_STRICT_CONCURRENCY from minimal → targeted → complete one level at a time, clearing warnings module by module. Jumping straight to complete floods you with hundreds of warnings and crushes morale.
The heart of this checklist is doing steps 2 and 3 before step 6. Maxing out strict checking first drowns you in warnings, but once you've built up type safety from the leaves, raising the checking level surfaces warnings in meaningful, fixable batches.
Summary
Swift Concurrency transforms how you write concurrent code in Rork Max. Here's what we covered:
async/await replaces callback pyramids with linear, readable code that doesn't block threads. Actor makes shared mutable state safe by serializing access at the type-system level. @MainActor declaratively ensures UI updates happen on the main thread. TaskGroup and async let express parallel work clearly, with automatic cleanup on cancellation or error. AsyncStream handles continuously arriving data from WebSockets, sensors, and other real-time sources. And Task.detached bridges these patterns safely into Rork Max's native module layer.
Rork 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.