Sitemap
Lucas Mrowskovsky Paim

Lucas Mrowskovsky Paim

iOS Sênior Developer

Common Swift-Concurrency mistakes that can be killing your app performance

8 min readAug 27, 2025

--

This article is compiled from common mistakes I have made in the past, as well as from mistakes I have found in codebases I have worked on over the last few years.

Photo by Adrien on Unsplash

#1 — Migrate from GCD without understanding the differences between concurrency and threading

Here is the first thing that you should understand: Swift concurrency isn't a replacement for GCD. So "migrating" is the wrong word for it, and in the rest of this article, I'll call it "updating" or "adopting". The fact is, if you're new to Swift, you should learn both, 'cause there are scenarios where concurrency will shine, but also have scenarios where GCD will shine, and you need to understand when to use one over the other (or both together!).

If you are a pre-Swift 5.5 iOS developer, GCD is essentially the way that we have for running parallel code. At that time, the mental model was simple: everything that you didn't want to execute in the main thread (UI thread) should be executed on a GCD queue; however, you needed something serial, concurrent, semaphores, etc.

The codebase at that time was any network/math-heavy, anything that could take seconds to execute, would look something like this:

func executeSomething( params: Foo, result: @escaping (Result<Output, Error>) -> Void ) { 
secondaryOrGlobalQueue.async {
let backgroundResult = Result { try someHeavyWork() } // blocking code
resultQueue.async {
result(backgroundResult.mapIntoResult())
}
}
}

This piece of code relies on a specific queue to execute the "heavy work" and return the result to a particular queue of results. That's a typical pattern in Swift 5.4- codebases.

Usually, when adopting swift concurrency, people make something like this:

func executeSomething( params: Foo ) async throws -> Output {
return try someHeavyWork() // 🙅‍♂️
}

But why is that wrong??

The code can look cleaner and simpler, but simply adding `async/await` hides a huge concept mistake.

From docs:

If you’ve written concurrent code before, you might be used to working with threads. The concurrency model in Swift is built on top of threads, but you don’t interact with them directly. An asynchronous function in Swift can give up the thread that it’s running on, which lets another asynchronous function run on that thread while the first function is blocked. When an asynchronous function resumes, Swift doesn’t make any guarantee about which thread that function will run on.

In Swift concurrency, you have something called a "thread pool" that the OS manages for you, which can be paused and resumed later. When that code is paused, the thread itself is free to perform other tasks, thereby increasing your app's performance.

Will this increase my app performance? Not precisely, swift concurrency shines when your code can be paused while waiting for an I/O response, such as a network call or file reading.

Performing heavy math or CPU-bound algorithms directly in the concurrency pool can introduce a problem in your concurrency pool, similar to thread explosion, known as pool exhaustion. In these scenarios, the GCD remains a shining example.

If you're unfamiliar with what a thread explosion is, I recommend reading this other article.

func executeSomething( params: Foo ) async throws -> Output {
return await withCheckedThrowingContinuation { continuation in
secondaryOrGlobalQueue.async {
let backgroundResult = Result { try someHeavyWork() }
continuation.resume(with: backgroundResult)
}
}
}

This code guarantees that the `someHeavyWork` function will be called in a controlled scope of `secondaryOrGlobalQueue` just like before, and the result will be delivered to the concurrency pool without blocking it.

#2 — (Un)Structured Concurrency

One of the primary goals of Swift concurrency is structured concurrency, but what does this term actually mean?

Child tasks have parents, and when a parent is cancelled, all children should also be cancelled.

When adopting concurrency, a common temptation is to sprinkle Task {} everywhere. It feels natural: you want some async work to start, so you wrap it in a task and move on.

func loadUser() {
Task {
let user = await fetchUser()
self.user = user
}
}

At first glance, it appears to be fine. But the problem is that this task is now unstructured. It’s completely disconnected from the caller. If the user leaves the screen, the task continues to run in the background. If the parent task is cancelled, this one won’t be. Over time, this leads to “orphan tasks” consuming resources and even mutating state after the UI is gone.

The correct approach is to utilize Swift’s structured concurrency, which ties child tasks to the lifetime of their parent.

func loadUser() async {
async let user = fetchUser()
self.user = await user
}

Or, if you need to run multiple concurrent requests, you can use a task group:

func loadData() async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { self.user = try await fetchUser() }
group.addTask { self.posts = try await fetchPosts() }
try await group.waitForAll()
}
}

Here, the child tasks inherit priority and cancellation from the parent. If the view disappears, all child work is automatically cancelled. That’s the essence of structured concurrency.

#3 — Blocking the main thread with sync APIs

Another mistake is thinking that adding async to a function magically makes everything inside it asynchronous. Developers often call synchronous APIs from async code, expecting them to behave non-blocking.

@MainActor
func loadImage() async {
let data = try! Data(contentsOf: url)
image = UIImage(data: data)
}

This blocks the main thread completely. The function is @MainActor, which means it runs on the UI thread, and Data(contentsOf:) is synchronous — so while the data loads, your UI is frozen.

Get Lucas Mrowskovsky Paim’s stories in your inbox

Join Medium for free to get updates from this writer.

The correct approach is to use an actual asynchronous API:

@MainActor
func loadImage() async {
let (data, _) = try! await URLSession.shared.data(from: url)
image = UIImage(data: data)
}

Now, the download occurs without blocking the main thread, and the UI remains responsive. Async/await doesn’t turn sync APIs into async ones — you need to pick the right API for the job.

#4 — Ignoring task cancellation

Swift tasks are cooperative: they won’t cancel themselves automatically. If you never check for Task.isCancelled, long-running work will continue executing even after the parent task is canceled. This is especially problematic in scenarios such as leaving a screen that is still streaming or processing data.

func streamData() async {
for await chunk in dataStream {
process(chunk) // keeps working even if cancelled
}
}

If the task is cancelled while the loop is running, the code above won’t stop until the stream naturally ends. That wastes CPU and battery.

A more mindful approach is to check for cancellation inside loops or between suspension points:

func streamData() async {
for await chunk in dataStream {
guard !Task.isCancelled else { return }
process(chunk)
}
}

With this pattern, as soon as the parent cancels the task, the function exits gracefully.

#5 —Misusing and overusing `@MainActor` / Assuming `await` == “run on background thread”

When developers first encounter @MainActor, it feels like the safest choice is to apply it to everything. After all, the UI runs on the main thread, so why not make all your code run there?

@MainActor
class DataManager {
func fetch() async -> [Item] {
return await slowDatabaseFetch() // running on main thread!
}
}

This works, but it forces all work — even slow database queries or heavy parsing — onto the main thread. The result? Janky scrolling, sluggish taps, and frozen UIs.

The correct approach is to separate concerns: keep heavy lifting off the main thread, and only jump to the main actor when updating the UI.

class DataManager {
func fetch() async -> [Item] {
await slowDatabaseFetch() // background work
}

@MainActor
func updateUI(with items: [Item]) {
// only UI updates are main-thread bound
}
}

#6 — Actors Don’t Guarantee Data Race Freedom, the Reentrancy Trap

Actors are designed to protect shared mutable state by ensuring that only one task runs inside them at a time. This sounds like a silver bullet for data races — and many developers assume actors are “thread-safe classes.”

But here’s the catch: actors are reentrant. That means that if an actor method suspends (e.g. hits an await), the actor can let other tasks run inside it before resuming. If those tasks mutate the actor’s state in the meantime, your function may resume in a world that looks very different from when it started.

Consider this example:

actor BankAccount {
private var balance: Int = 0

func withdraw(_ amount: Int) async throws {
guard balance >= amount else {
throw AccountError.insufficientFunds
}

try await Task.sleep(nanoseconds: 1_000_000_000) // simulate delay
balance -= amount
}
}

At first glance, this looks safe: we check the balance, wait a bit, then subtract. However, due to reentrancy, after the await Task.sleep, another task might have altered the balance (for example, another withdrawal). The check you performed earlier is no longer valid, and you may end up overdrawing your account.

To avoid this, you need to design actor methods to keep invariants in mind across suspension points. One strategy is to separate the “check” and “mutation” parts into different isolated calls, or to avoid suspension between the two.

actor BankAccount {
private var balance: Int = 0

private func subtractBalance(_ amount: Int) throws {
guard balance >= amount else {
throw AccountError.insufficientFunds
}
balance -= amount // no suspension, atomic inside actor
}

func withdraw(_ amount: Int) async throws {
try subtractBalance(amount) // lock held, no suspension
try await notifyBackend(amount) // suspension happens *after*
}
}

In this version, the critical mutation happens entirely inside the actor before any await point. If you need to interact with external async systems, do so after updating internal state.

Actors guarantee isolation — but reentrancy means you still need to think carefully about where you suspend. Otherwise, your code may resume in a completely different world than you expected.

#7 —Misunderstanding of Task.detached

It’s easy to assume Task.detached is just another way to launch async work. Still, there’s a critical difference: detached tasks don’t inherit anything from their parent — not actor context, not priority, not cancellation.

@MainActor
func something() async {
Task.detached {
await updateUI() // runs outside MainActor!
}
}

If you expected this to run on the main thread because updateUI is @MainActor, you’re in for a surprise. Detached tasks don’t care.

Most of the time, what you want is a plain Task { … }, which inherits from the parent:

func something() {
Task {
await updateUI() // runs on MainActor, respects cancellation
}
}

Reserve detached for truly independent work that shouldn’t be tied to the current task or actor. Otherwise, stick with structured tasks.

#8 —Forgetting backpressure / too many tasks

Swift tasks are lightweight — but that doesn’t mean you can spawn thousands of them without consequences. Creating a task inside a loop might feel natural, but it can easily overwhelm the cooperative thread pool.

for i in 0..<10000 {
Task {
await doWork(i) // pool exhaustion
}
}

This can degrade performance badly, as the system thrashes trying to manage more tasks than it can handle efficiently.

The correct way is to use a task group, which gives you structured concurrency and backpressure:

let maxConcurrentTasks: Int = 10
try await withThrowingTaskGroup(of: Void.self) { group in
for i in 0..<10000 {
group.addTask {
try await doWork(i)
}
// waits at least one task finish before adding another one
if i % maxConcurrentTasks == 0 { try await group.next() }
}
try await group.waitForAll()
}

Here, you can control how many concurrent tasks you want to have.

Final Thoughts

Swift Concurrency is incredibly powerful, but like every tool, it comes with sharp edges. Many of the mistakes above don’t show up as crashes — they silently hurt performance, drain battery, or make your UI feel sluggish.

The takeaway: async/await is not just syntactic sugar over GCD. It’s a different mental model, one that rewards careful thinking about structure, cancellation, and isolation. Adopt it mindfully, and your app (and your future self) will thank you.

--

--