Most Flutter developers write Swift only when they have to: a platform channel for a native SDK, a plugin for Bluetooth or payments, a widget extension. That Swift code has never been the main event, which is exactly why Swift's strict concurrency checking caught so many Flutter teams off guard. Turn on Swift 6 language mode and a 200-line plugin can produce dozens of "sending risks causing data races" errors.
Swift 6.2, which ships with Xcode 26, changes the defaults so that code runs where you'd naively expect it to run, and you opt in to parallelism explicitly. For Flutter's native layer, that's a much better fit. There are three pieces to understand.
1. Default actor isolation: MainActor
Xcode 26 adds a Default Actor Isolation build setting with two values: MainActor or nonisolated. With MainActor, every declaration without an explicit annotation is treated as if it were marked @MainActor. New Xcode projects default to MainActor; existing projects keep nonisolated. A Flutter Runner project created years ago is an existing project, so nothing changes until you choose to switch.
For a Flutter app's native layer, MainActor-by-default is usually the right model:
- Platform channel handlers on iOS are invoked on the main thread, and replies have historically been expected there too.
- UIKit views (platform views, native pickers, the root view controller) must be used on the main thread.
- Most plugin code is glue: receive arguments, call a native API, send a result back. None of that needs parallelism.
The setting only affects the module it's set on. Other modules, including Apple's frameworks and third-party SDKs, keep their own isolation. Swift packages, which is how Flutter plugins are now distributed (see Flutter's move to Swift Package Manager), can opt in per target through a Swift setting in the package manifest.
2. nonisolated(nonsending): async functions stay where they're called
Before Swift 6.2, a nonisolated async function always hopped off the caller's actor onto the global concurrent executor. Call such a helper from main-actor plugin code and your arguments suddenly crossed an isolation boundary. That boundary crossing produced most of the "sending" errors people fought with in Swift 6.
With nonisolated(nonsending) as the default (SE-0461, enabled by the NonisolatedNonsendingByDefault upcoming feature or the Approachable Concurrency build setting), a nonisolated async function runs on whatever actor called it. A helper called from the main actor stays on the main actor. Passing a non-Sendable dictionary of channel arguments to it is no longer a cross-actor send, so the error disappears, correctly.
3. @concurrent: parallelism you ask for
If everything stays on the caller's actor, what about genuinely heavy work? That's what @concurrent is for. Mark a function @concurrent and it runs on the concurrent executor, off the main actor, and the compiler checks everything that crosses into it.
In a Flutter plugin, the functions that deserve @concurrent are easy to find:
- Decoding, resizing or compressing images and video frames.
- Parsing large binary payloads, such as BLE packets, firmware files or custom protocols.
- Cryptography, hashing and signature checks.
- Local file and database work big enough to drop frames if it ran on the main thread.
Everything else, including argument parsing, calling the SDK, and replying to the channel, can stay on the main actor. The result is a plugin where the expensive work is explicit and easy to find in review, instead of whatever happened to end up on a background queue.
Practical rules for Flutter native code
Convert at the boundary. Channel arguments arrive as loosely typed dictionaries and arrays. Convert them into small Sendable value types, structs with let properties, as the first step in the handler, and pass those into @concurrent work. Never pass channel objects, result callbacks or UIKit objects into background work.
Reply on the main actor. Do the work in a @concurrent function, await it from the main-actor handler, then send the result back to Dart. That keeps reply threading correct without manual dispatch-queue juggling.
Watch delegate-based SDKs. Many native SDKs, such as CoreBluetooth, location and media players, deliver callbacks on a queue you pass in or on the main queue by default. If you give an SDK a background queue, its delegate methods aren't on the main actor, and with MainActor default isolation your delegate class has to say so explicitly with nonisolated members. Decide per SDK, and document it in the plugin.
Don't block the main actor. MainActor-by-default makes it easier to accidentally run slow synchronous work on the main thread. On iOS that janks your Flutter UI just as badly as slow Dart code does. Profile the native side with Instruments when a platform-channel call shows up in Flutter DevTools as a long frame.
A low-risk adoption path
- Enable the individual upcoming-feature flags (or Approachable Concurrency) while still in Swift 5 language mode, and fix what the compiler reports.
- Switch Default Actor Isolation to
MainActorfor your plugin targets and theRunnertarget. Expect to add somenonisolatedannotations where code was meant to run concurrently. - Mark the heavy functions
@concurrenton purpose. - Move to Swift 6 language mode once the warnings are gone. By then it's mostly a formality.
Swift 6.2 doesn't make concurrency disappear. It makes the default safe and the exceptions visible, which is exactly the property you want in the thin native layer underneath a Flutter app.
I build Flutter apps with native iOS and Android integrations, including Bluetooth, IoT and payment SDKs. See Flutter app development or get in touch.