- Published on
- 6 min read Intermediate
> Using @concurrent in Swift 6.2
Swift 6.2 quietly changed something fundamental about async functions. Before, an unmarked nonisolated async function would jump to the global executor and run on a background thread. Now it inherits the caller's actor by default, which means an await from your @MainActor view model keeps running on the main thread unless you say otherwise.
The new @concurrent attribute is how you say otherwise.
Why the Default Changed
The motivation comes from SE-0461: Run nonisolated async functions on the caller's actor by default. Before this change, calling await someFunction() from a main-actor context would silently hop threads, which was a constant source of confusion for people learning Swift concurrency. You'd write what looked like simple sequential code, then watch Sendable errors pile up because every await was crossing an isolation boundary.
Here is how it used to behave:
@MainActor
final class FeedViewModel {
func loadFeed() async throws {
let data = try await fetchData()
// fetchData() ran on the global executor
// anything captured here had to be Sendable
}
}
Under the new defaults, fetchData() runs on the main actor along with the rest of loadFeed(). No more accidental thread hops, and no more Sendable warnings on values you never intended to share. You enable the new behavior by setting the defaultIsolation in your package manifest or by adopting the upcoming AsyncCallerExecution feature flag, and it becomes default in Swift 6.2's language mode.
That is great for correctness, but it leaves an obvious question: how do you actually push work onto a background thread when you want to? That is what @concurrent is for.
The @concurrent Attribute
Marking a function with @concurrent makes it run on the global executor regardless of where it was called from. The compiler treats it as nonisolated, so you do not need both attributes:
@concurrent
func decodeFeed(from data: Data) async throws -> Feed {
let decoder = JSONDecoder()
return try decoder.decode(Feed.self, from: data)
}
Calling decodeFeed(from:) from a @MainActor context now hops off the main thread, does the decoding work, then returns control to the main actor when it finishes. You get the old "run on a background thread" behavior, but only when you actually ask for it.
The same attribute works inside actors and main-actor-isolated classes:
@MainActor
final class FeedViewModel {
func loadFeed() async throws -> Feed {
let (data, _) = try await URLSession.shared.data(from: Feed.endpoint)
return try await decodeFeed(from: data)
}
@concurrent
private func decodeFeed(from data: Data) async throws -> Feed {
try JSONDecoder().decode(Feed.self, from: data)
}
}
FeedViewModel is @MainActor isolated, so loadFeed() and the URL session call all stay on the main thread. But when it reaches decodeFeed(from:), the @concurrent attribute kicks the work over to the global executor. The view model goes back to the main actor as soon as decoding finishes. The UI never blocks on the parsing work, even for a chunky JSON payload.
What You Cannot Combine It With
@concurrent is mutually exclusive with explicit isolation. You cannot pair it with @MainActor, a custom global actor, or nonisolated(nonsending). Trying produces a compile error:
@concurrent @MainActor
func decode(_ data: Data) async throws -> Feed { ... }
// error: cannot apply @concurrent to a @MainActor function
@concurrent nonisolated(nonsending)
func decode(_ data: Data) async throws -> Feed { ... }
// error: redundant or conflicting isolation
This makes sense if you think about what each attribute is asking for. @MainActor says "always run on the main thread." nonisolated(nonsending) says "run on whatever actor my caller is using." @concurrent says "always run on the global executor." Pick one.
When to Reach for It
The honest answer is: less often than you might think. The whole point of the default-isolation change was to keep code on a single actor as much as possible, because that is where data races cannot happen. Adding @concurrent reintroduces an isolation boundary, which means anything you pass across it has to be Sendable, and anything you capture from the surrounding context follows the same rule.
Save it for work that genuinely benefits from running off the caller's actor. Large JSON or Plist decoding, image processing, expensive computations, hashing, compression, anything CPU bound that would stall the UI if it ran on the main thread. Mark only the smallest piece of work that needs to escape:
final class ImageProcessor {
@MainActor
func process(_ image: UIImage) async throws -> UIImage {
let prepared = prepare(image)
let filtered = try await applyHeavyFilter(prepared)
return finalize(filtered)
}
@concurrent
private func applyHeavyFilter(_ image: UIImage) async throws -> UIImage {
try heavyFilterPipeline(image)
}
}
Notice how only applyHeavyFilter gets the attribute. prepare and finalize stay on the main actor where they belong, and the only thing that crosses the isolation boundary is the UIImage going in and out. That keeps the Sendable surface area as small as possible.
The thing not to do is sprinkle @concurrent on every async function out of habit. A network call like this gets nothing from it:
@concurrent
func loadFeed() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: Feed.endpoint)
return data
}
URLSession.data(from:) already suspends while it waits for bytes to arrive, and the actual networking happens deep inside the system. Adding @concurrent here just creates an extra isolation boundary that buys you nothing. The function spends its time suspended, not blocking a thread.
A Mental Model
Think of @concurrent as the modern, narrow equivalent of DispatchQueue.global().async. You are not asking the function to be async, it already is. You are asking the runtime to make sure this particular piece of work runs on a background thread, because otherwise it would block whatever actor called it.
Most async code does not need that. The functions that do are the ones doing CPU-bound work between suspension points, not the ones that suspend immediately and wait. When you find one of those, reach for @concurrent. The rest of the time, let Swift 6.2's defaults keep your code on a single actor and out of trouble.
// Continue_Learning
Task Cancellation Shields in Swift 6.4
Swift 6.4's withTaskCancellationShield lets cleanup code run to completion even after a task has been cancelled, without spawning extra unstructured tasks.
Error Handling in Swift: From Basics to Typed Throws
A practical guide to Swift error handling covering throw/try/catch fundamentals, custom error types, Result, async error patterns, and typed throws introduced in Swift 6.
TaskGroup and Structured Concurrency Patterns in Swift
Master Swift's TaskGroup for parallel async work with proper cancellation, error handling, and result collection. Learn common patterns for batch operations and concurrent pipelines.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.