Using Instruments to profile a SwiftUI app

A key skill for every app developer is being able to profile your app's performance. Your app might look great on the surface, but if it doesn’t perform well, it’s going to feel off—sometimes subtly, sometimes very noticeably. Beautiful animations, slick interactions, and large data sets all fall flat if the app feels sluggish or unresponsive.

Great apps respond instantly. They show that you’ve tapped something right away, and they make interactions feel smooth and satisfying.

To make sure your app behaves like that, you’ll need to keep an eye on its performance. In this post, we’ll look at how you can use Instruments to profile your SwiftUI app. We’ll cover how to detect slow code, track view redraws, and understand when and why your UI updates. If you're interested in a deeper dive into SwiftUI redraws or profiling slow code, check out these posts:

We’ll start by building your app for profiling, then look at how to use Instruments effectively—both for SwiftUI specifics and general performance tracking.

Building an app for profiling

The first step is to build your app using Product > Profile, or by pressing Cmd + I (sidenote: I highly recommend learning shortcuts for command you use frequently).

Use Product->Profile or cmd+i

This builds your app in Release mode, using the same optimizations and configurations as your production build.

This is important because your development build (Debug mode) isn’t optimized. You might see performance issues in Debug that don’t exist in Release. I recently had this happen while working with large data sets—code ran pretty horrible in Debug was optimized in Release to the point of no longer being a problem at all.

When this happens, it usually means there’s some inefficiency under the hood, but I wouldn’t spend too much time on issues that disappear in Release mode when you have bigger issues to work on.

Once your app is built and Instruments launches, you’ll see a bunch of templates. For SwiftUI apps, the SwiftUI template is usually the right choice—even if you’re not profiling SwiftUI-specific issues. It includes everything you need for a typical SwiftUI app.

Choosing a template

After picking your template, Instruments opens its main window. Hit the red record button to start profiling. Your app will launch, and Instruments will start collecting data in real-time based on the instruments you selected. The SwiftUI template collects everything in real-time.

Instruments overview

Reading the collected data

Instruments organizes its data into several lanes. You’ll see lanes like View Body, View Properties, and Core Animation Commits. Let’s go through them from top to bottom.

Viewing recorded data

Note that I’m testing on a physical device. Testing on the simulator can work okay for some use cases but results can vary wildly between simulators and devices due to the resources available to each. It’s always recommended to use a device when testing for performance.

The View Body lane

This lane shows how often a SwiftUI view’s body is evaluated. Whenever SwiftUI detects a change in your app’s data, it re-evaluates the body of any views that depend on that data. It then determines whether any child views need to be redrawn.

So, this lane essentially shows you which views are being redrawn and how often. If you click the timing summary, you’ll see how long these evaluations take—total, min, max, and average durations. This helps you identify whether a view’s body is quick or expensive to evaluate.

Exploring the view body lane

By default, Instruments shows data for the entire profiling session. That means a view that was evaluated multiple times may have been triggered by different interactions over time.

Usually, you’ll want to profile a specific interaction. You can do this by dragging across a timeframe in the lane. This lets you zoom in on a specific window of activity—like what happens when you tap a button.

Once you’ve zoomed in, you can start to form a mental model.

For example, if tapping a button increases a counter, you’d expect the counter view’s body to be evaluated. If other views like the button’s parent also redraw, that might be unexpected. Ask yourself: did I expect this body to be re-evaluated? If not, it’s time to look into your code.

In my post on SwiftUI view redraws, I explain more about what can cause SwiftUI to re-evaluate views. It’s worth a read if you want to dig deeper.

View Properties and Core Animation Commits

The View Properties and Core Animation Commits lanes are ones I don’t use very often.

In View Properties, you can see which pieces of state SwiftUI tracked for your views and what their values were. In theory, you can figure out how your data model changed between body evaluations—but in practice, it’s not always easy to read.

Core Animation Commits shows how much work Core Animation or the GPU had to do when redrawing views. Usually, it’s not too heavy, but if your view body takes a long time to evaluate, the commit tends to be heavier too.

I don’t look at this lane in isolation, but it helps to get a sense of how expensive redrawing became after a body evaluation.

Reading the Time Profiler

The Time Profiler might be the most useful lane in the SwiftUI Instruments template. It shows you which code was running on which thread, and how long it was running.

You’re essentially seeing snapshots of the CPU at short intervals. This gives you insight into how long specific functions were active.

When profiling SwiftUI apps, you’ll usually be interested in code related to your data model or views. If a function updates your data and appears slow, or if it’s called from a view body, that might explain a performance issue.

Configuring the time profiler

Getting comfortable with the time profiler takes a bit of practice. I recommend playing around with the call tree settings. I usually:

  • Separate by thread
  • Invert the call tree
  • Hide system libraries

Sometimes, I tweak these settings depending on what I’m trying to find. It’s worth exploring.

In summary

Profiling your code and understanding how to use Instruments is essential if you want to build responsive, high-quality apps. As your app grows, it gets harder to mentally track what should happen during an interaction.

The tricky part about using Instruments is that even with a ton of data, you need to understand what your app is supposed to be doing. Without that, it’s hard to tell which parts of the data matter. Something might be slow—but that might be okay if it’s processing a lot of data.

Still, getting into the habit of profiling your app regularly helps you build a sense of what’s normal and what’s not. The earlier and more often you do this, the better your understanding becomes.

Expand your learning with my books

Practical Swift Concurrency header image

Learn everything you need to know about Swift Concurrency and how you can use it in your projects with Practical Swift Concurrency. It contains:

  • Eleven chapters worth of content.
  • Sample projects that use the code shown in the chapters.
  • Free updates for future iOS versions.

The book is available as a digital download for just $39.99!

Learn more

Staying productive as an indie developer

Okay. I’m using the term indie developer loosely here. I don’t consider myself to be an indie developer. But I am independent, mostly. I run my own business where I work on client apps, workshops, my books, this website, my YouTube channel, and more. So I think I qualify as indie, partially.

Either way, in this post I’d like to explore something that I don’t write or talk about much. How do I, as someone that manages my own time and work, make sure that I stay productive (in a sustainable way).

A lot of folks that I’ve talked to over the years have their own systems that help them plan, structure, and execute work. Some folks are just so eager to always be working that they have no problems staying busy at all. Others, myself included, struggle with maintaining a rhythm, finding balance, and avoid procrastinating.

Over the last half year or so I’ve been very actively trying to figure out what works for me, and in this blog post I’d like to share some of the things that I’ve learned in that time.

It all starts with a plan

The quality of work that I’ll do in a day, week, or month, all depends on my plans for a given timeframe. If I leave my Friday afternoon with unfinished business, and I spend my Monday on trying to figure out where I’ve left off and I don’t make a plan for the week, it’s likely that the week will not be very productive.

That’s because once I start working without a plan I’m in a constant mode of trying to figure out what’s next. I’ll find something to do, do it, and then spend a lot of time wondering about “what’s next?”.

Usually that means I take a break, go on social media, or watch some YouTube videos. Basically, I start to procrastinate.

I’ve tried to manage this by allocating every morning and afternoon to a type of work. I started off by separating “client” work and “blog” work. This kind of worked but it was too ambiguous. I’d spend a lot of time trying to figure out which client to work for, and what to work on. Or I’d be thinking about whether I wanted to work on an app, a YouTube video, my blog, or something else.

The result?

I’d end up doing far less than I wanted to. Especially when it came to “blog” work because there’s virtually no accountability there.

So, I solved this problem by planning differently. Instead of allocating stuff to days I’d have a list of things to do for the week. In my calendar I would still block some times for types of work, but my list is leading.

When it’s time to do client work, I look at my list. Who’s on top (the list is sorted by urgency)? That’s what I’ll work on. It’s simple yet effective.

I thought that this system would be too loose for me to allow me to properly plan but I think it’s working well. I started doing this about three months ago and I can definitely tell that my output is getting better. I’m also feeling better about my work than I did half a year ago.

So, start with a plan. Make sure you always know what’s next. Knowing what you’re supposed to do makes doing it more straightforward.

Find the right environment

I know, we’re all supposed to love working from home. I’m not great at it. I love the idea of it, but I’m really not good at it.

Whenever I work from home, there’s loads of distractions. I can hang around and watch TV for a bit. Do some work in the garden. Run a quick errand. Fold laundry. Cook.

These are all things I either enjoy doing, or things that need to get done one way or another.

Focusing while working from home can be quite a challenge for me, and I’m finding out more and more that my desk isn’t always the place where I’m most productive.

Going some place that’s not the same desk every day can be a big boost for my productivity. I’m not sure whether a change of environment just keeps my brain active, or I’m better at performing certain tasks from a different location (sometimes it’s just a different place in my house), but changing it up works for me.

If you’re feeling unproductive and you feel like you’ve got a handle on knowing what you’re supposed to do but doing it seems hard, try and switch things up a bit. Work from a coffee shop, a co-working space, a library, or your dinner table instead of your desk and see how it feels. Maybe it can help you focus on different things.

Track your work

I used to think that having rigid systems for work would suck the joy out of doing the work. The more I rely on systems and routines the more I find that I enjoy the work I’m doing because it allows me to be more focused, less stressed, and feel more in control.

By making tickets for everything I do, I have a really good sense of where I should focus my attention. I can easily measure how productive I’ve been over time just by looking at the tickets I’ve closed.

I’m using GitHub projects for tracking my work, and I’m enjoying it a lot. I can link my tickets to issues which makes it easy to link everything together. By ordering my tickets based on their priority I’m basically able to run through my list from top to bottom throughout the week and know that I’m always doing the right thing.

In addition to tracking my work through tickets, I’ve created a utility app for myself to track with Chrona I can track pomodoro timers throughout my workday. Every timer gets a description of what I’ve worked on and a rating for how well I think I did productivity-wise.

The idea is that Chrona will eventually help me gain more understanding regarding the way I do my work, and whether I have any times of day where my productivity drops significantly. If there’s any patterns in my productivity or the type of work I do I can anticipate for that when I make my plans, or I could decide to structure my work week around times and days that work well for me.

Chrona is one of four apps that I intend to ship this year with the main thought being that these apps should be useful for myself before thinking about how to make them into products. That’s why Chrona was build to be a simple app that only does a few things. It’s not configurable nor customizable and that’s on purpose. I might add more features in the future if enough folks use the app.

In Summary

Productivity is a complex topic, and if there’s one thing I’ve learned over time it’s that anybody that claims to have the answer for all your productivity woes is lying to you. Yes, there are proven systems out there. But the effectiveness of these systems isn’t guaranteed for everybody. Some folks work different form others, and that’s completely fine.

If you’re struggling to keep yourself motivated and focused, I highly recommend to actively try and understand what’s making things hard for you. When and why are you procrastinating, and what can you do to fix that. For me, knowing what’s next (planning), where I work (environment), and having objective insights (tracking) seem to be having a positive impact.

The key factor for me seems to be that there’s no point in trying to force any particular system on myself. If it doesn’t work, I try and understand why, and then I make changes to my routines to see whether things improve. It’s an iterative process, and its not a quick one.

Implementing Task timeout with Swift Concurrency

Swift Concurrency provides us with loads of cool and interesting capabilities. For example, Structured Concurrency allows us to write a hierarchy of tasks that always ensures all child tasks are completed before the parent task can complete. We also have features like cooperative cancellation in Swift Concurrency which means that whenever we want to cancel a task, that task must proactively check for cancellation, and exit when needed.

One API that Swift Concurrency doesn't provide out of the box is an API to have tasks that timeout when they take too long. More generally speaking, we don't have an API that allows us to "race" two or more tasks.

In this post, I'd like to explore how we can implement a feature like this using Swift's Task Group. If you're looking for a full-blown implementation of timeouts in Swift Concurrency, I've found this package to handle it well, and in a way that covers most (if not all edge cases).

Racing two tasks with a Task Group

At the core of implementing a timeout mechanism is the ability to race two tasks:

  1. A task with the work you're looking to perform
  2. A task that handles the timeout

whichever task completes first is the task that dictates the outcome of our operation. If the task with the work completes first, we return the result of that work. If the task with the timeout completes first, then we might throw an error or return some default value.

We could also say that we don't implement a timeout but we implement a race mechanism where we either take data from one source or the other, whichever one comes back fastest.

We could abstract this into a function that has a signature that looks a little bit like this:

func race<T>(
  _ lhs: sending @escaping () async throws -> T,
  _ rhs: sending @escaping () async throws -> T
) async throws -> T {
  // ...
}

Our race function take two asynchronous closures that are sending which means that these closures closely mimic the API provided by, for example, Task and TaskGroup. To learn more about sending, you can read my post where I compare sending and @Sendable.

The implementation of our race method can be relatively straightforward:

func race<T>(
  _ lhs: sending @escaping () async throws -> T,
  _ rhs: sending @escaping () async throws -> T
) async throws -> T {
  return try await withThrowingTaskGroup(of: T.self) { group in
    group.addTask { try await lhs() }
    group.addTask { try await rhs() }

    defer { group.cancelAll() }

    return try await group.next()!
  }
}

We're creating a TaskGroup and add both closures to it. This means that both closures will start making progress as soon as possible (usually immediately). Then, I wrote return try await group.next()!. This line will wait for the next result in our group. In other words, the first task to complete (either by returning something or throwing an error) is the task that "wins".

The other task, the one that's still running, will be marked as cancelled and we ignore its result.

There are some caveats around cancellation that I'll get to in a moment. First, I'd like to show you how we can use this race function to implement a timeout.

Implementing timeout

Using our race function to implement a timeout means that we should pass two closures to race that do the following:

  1. One closure should perform our work (for example load a URL)
  2. The other closure should throw an error after a specified amount of time

We'll define our own TimeoutError for the second closure:

enum TimeoutError: Error {
  case timeout
}

Next, we can call race as follows:

let result = try await race({ () -> String in
  let url = URL(string: "https://www.donnywals.com")!
  let (data, _) = try await URLSession.shared.data(from: url)
  return String(data: data, encoding: .utf8)!
}, {
  try await Task.sleep(for: .seconds(0.3))
  throw TimeoutError.timeout
})

print(result)

In this case, we either load content from the web, or we throw a TimeoutError after 0.3 seconds.

This approach to implementing a timeout doesn't look very nice. We can define another function to wrap up our timeout pattern, and we can improve our Task.sleep by setting a deadline instead of duration. A deadline will ensure that our task never sleeps longer than we intended.

The key difference here is that if our timeout task starts running "late", it will still sleep for 0.3 seconds which means it might take a but longer than 0.3 second for the timeout to hit. When we specify a deadline, we will make sure that the timeout hits 0.3 seconds from now, which means the task might effectively sleep a bit shorter than 0.3 seconds if it started late.

It's a subtle difference, but it's one worth pointing out.

Let's wrap our call to race and update our timeout logic:

func performWithTimeout<T>(
  of timeout: Duration,
  _ work: sending @escaping () async throws -> T
) async throws -> T {
  return try await race(work, {
    try await Task.sleep(until: .now + timeout)
    throw TimeoutError.timeout
  })
}

We're now using Task.sleep(until:) to make sure we set a deadline for our timeout.

Running the same operation as before now looks as follows:

let result = try await performWithTimeout(of: .seconds(0.5)) {
  let url = URL(string: "https://www.donnywals.com")!
  let (data, _) = try await URLSession.shared.data(from: url)
  return String(data: data, encoding: .utf8)!
}

It's a little bit nicer this way since we don't have to pass two closures anymore.

There's one last thing to take into account here, and that's cancellation.

Respecting cancellation

Taks cancellation in Swift Concurrency is cooperative. This means that any task that gets cancelled must "accept" that cancellation by actively checking for cancellation, and then exiting early when cancellation has occured.

At the same time, TaskGroup leverages Structured Concurrency. This means that a TaskGroup cannot return until all of its child tasks have completed.

When we reach a timeout scenario in the code above, we make the closure that runs our timeout an error. In our race function, the TaskGroup receives this error on try await group.next() line. This means that the we want to throw an error from our TaskGroup closure which signals that our work is done. However, we can't do this until the other task has also ended.

As soon as we want our error to be thrown, the group cancels all its child tasks. Built in methods like URLSession's data and Task.sleep respect cancellation and exit early. However, let's say you've already loaded data from the network and the CPU is crunching a huge amount of JSON, that process will not be aborted automatically. This could mean that even though your work timed out, you won't receive a timeout until after your heavy processing has completed.

And at that point you might have still waited for a long time, and you're throwing out the result of that slow work. That would be pretty wasteful.

When you're implementing timeout behavior, you'll want to be aware of this. And if you're performing expensive processing in a loop, you might want to sprinkle some calls to try Task.checkCancellation() throughout your loop:

for item in veryLongList {
  await process(item)
  // stop doing the work if we're cancelled
  try Task.checkCancellation()
}

// no point in checking here, the work is already done...

Note that adding a check after the work is already done and just before you return results doesn't really do much. You've already paid the price and you might as well use the results.

In Summary

Swift Concurrency comes with a lot of built-in mechanisms but it's missing a timeout or task racing API.

In this post, we implemented a simple race function that we then used to implement a timeout mechanism. You saw how we can use Task.sleep to set a deadline for when our timeout should occur, and how we can use a task group to race two tasks.

We ended this post with a brief overview of task cancellation, and how not handling cancellation can lead to a less effective timeout mechanism. Cooperative cancellation is great but, in my opinion, it makes implementing features like task racing and timeouts a lot harder due to the guarantees made by Structured Concurrency.

How to plan a migration to Swift 6

Swift 6 has been available to us for the better part of a year now, and more and more teams are considering or looking at migrating to the Swift 6 language mode. This typically involves trying to turn on the language mode or turning on strict concurrency, seeing a whole bunch of warnings or errors, and then deciding that today is not the day to proceed with this migration.

Today I would like to propose an approach to how you can plan your migration in a way that won’t scare you out of attempting the migration before you’ve even started.

Before you go through this entire post expecting me to tell you how to go to Swift 6 within a matter of days or weeks, I'm afraid I'm going to have to disappoint you.

Migrating to Swift 6, for a lot of apps, is going to be a very slow and lengthy process and it's really a process that you don't want to rush.

Taking an initial inventory

Before you start to migrate your codebase, I would highly recommend that you take inventory of the state of your codebase. This means that you should take a look at how modularized your codebase is, which dependencies you have in your codebase, and maybe most importantly how much concurrency you’re really using right now. Find out how often you’re explicitly, and purposefully you’re leaving the main thread. And try to understand how much of your code will run on the main thread.

You should also look at your team and figure out how up-to-date your team is, how comfortable they are with Swift concurrency already. In the end, the entire team will need to be able to work on and with your Swift 6 codebase.

On a code level, it's essential to understand how much concurrency you actually need because Swift concurrency is, by design, going to introduce a lot of concurrency into your app where maybe you don't actually need all of that concurrency. That’s why it’s so important for you to figure the amount of concurrency you’ll require beforehand by analyzing what you have now.

For example, if you have a view and you have a view model, and that view model talks to another layer, then probably you are doing most of the work on the main thread right now.

Once you hit your networking layer, your network calls will run somewhere else, and when your networking related functions invoke their callbacks, those will typically run on a background thread, and then you come back to the main thread to update your UI.

In this scenario, you don't need a lot of concurrency; in fact, I would say that you don't need concurrency beyond what URLSession provides at all. So once you’re adopting Swift Concurrency, you’ll want to understand how you can structure your code to not leave the main thread for every async call.

You might already have adopted async-await, and that's completely fine—it probably means that you do have more concurrency than you actually need. Every nonisolated async function that you write will, by default, run on a background thread. You don’t always need this; you’ll most likely want to explicitly isolate some of your work to the main actor to prevent leveraging concurrency in places where it’s simply not benefitting you.

You'll also want to make sure that you understand how dependent or how coupled your codebase is because the more coupling you have and the less abstractions and modularization you have, the more complexities you might run into. Understanding your codebase deeply is a prerequisite to moving to Swift 6.

Once you understand your codebase, you might want to look at modularizing. I would say this is the best option. It does make migrating a little bit easier.

So let's talk about modularization next.

Modularizing your codebase

When you migrate to Swift 6, you'll find that a lot of objects in your code are being passed from one place to another, and when you start to introduce concurrency in one part of the code, you’re essentially forced to migrate anything that depends on that part of your codebase.

Having a modularized codebase means that you can take your codebase and migrate it over time. You can move component by component, rather than being forced to move everything all at once.

You can use features like @preconcurrency to make sure that your app can still use your Swift 6 modules without running into all kinds of isolation or sendability warnings until your app also opts in to strict concurrency.

If you don't want to modularize your codebase or you feel your codebase is way too small to be modularized, that's completely fine. I'm just saying that the smaller your components are, the easier your migration is going to be.

Once you know the state your codebase is in and you feel comfortable with how everything is, it's time to turn on strict concurrency checks.

Turning on strict concurrency checks

Before you turn on Swift 6 language mode, it is recommended to turn on strict concurrency checking for the modules that you want to migrate. You can do this for both SPM and in Xcode for your app target.

I would recommend to do this on a module by module basis.

So if you want to refactor your models package first, turn on strict concurrency checks for your model package, but not yet for your app. Turning on strict concurrency for only one module allows you to work on that package without forcing your app to opt into all of the sendability and isolation checks related to the package you’re refactoring.

Being able to migrate one package at a time is super useful because it makes everything a lot easier to reason about since you’re reasoning about smaller bits of your code.

Once you have your strict concurrency checks turned on you're going to see a whole bunch of warnings for the packages and targets where you've enabled strict concurrency and you can start solving them. For example, it’s likely that you'll run into issues like main actor isolated objects to sendable closures.

You'll want to make sure that you understand these errors before you proceed.

You want to make sure that all of your warnings are resolved before you turn on Swift 6 language mode, and you want to make sure that you've got a really good sense of how your code is supposed to work.

The hardest part in solving your strict concurrency warnings is that making the compiler happy sometimes just isn't enough. You'll frequently want to make sure that you actually reason about the intent of your code rather than just making the compiler happy.

Consider the following code example:

func loadPages() {
  for page in 0..<10 {
    loadPage(page) { result in 
      // use result
    }
  }
}

We're iterating over a list of numbers and we're making a bunch of network calls. These network calls happen concurrently and our function doesn't wait for them all to complete. Now, the quickest way to migrate this over to Swift concurrency might be to write an async function and a for loop that looks like this:

func loadPages() async throws {
  for page in 0..<10 {
    let result = try await loadPage(page)
    // use result
  }
}

The meaning of this code has now changed entirely. We're making network calls one by one and the function doesn't return until every call is complete. If we do want to introduce Swift concurrency here and keep the same semantics we would have to create unstructured tasks for every single network call or we could use a task group and kick off all our network calls in parallel that way.

Using a task group would change the way this function works, because the function would have to wait for the task group to complete rather than just letting unstructured tasks run. In this refactor, it’s crucial to understand what structured concurrency is and when it makes sense to create unstructured tasks.

You're having to think about what the intent of the code is before you migrate and then also how and if you want to change that during your migration. If you want to keep everything the same, it's often not enough to keep the compiler happy.

While teaching Teams about Swift Concurrency, I found it really important to know exactly which tools you have out there and to think about how you should be reasoning about your code.

Once you've turned on Swift Concurrency checks, it's time to make sure that your entire team knows what to do.

Ensuring your team has all the knowledge they need

I've seen several companies attempt migrations to SwiftUI, Swift Data, and Swift Concurrency. They often take approaches where a small team does all the legwork in terms of exploring and learning these technologies before the rest of the team is requested to learn them too and to adopt them. However, this often means that there's a small team inside of the company that you could consider to be experts. They'll have had access to resources, they'll have had time to train, and once they come up with the general big picture of how things should be done, the rest of the team kind of has to learn on the job. Sometimes this works well, but often this breaks down because the rest of the team simply needs to fully understand what they're dealing with before they can effectively learn.

So I always recommend if you want to migrate over to Swift Concurrency have your team enroll in one of my workshops or use my books or my course or find any other resource that will teach the team everything they need to know. It's really not trivial to pick up Swift Concurrency, especially not if you want to go into strict concurrency mode. Writing async-await functions is relatively easy, but understanding what happens is really what you need if you're planning to migrate and go all-in on concurrency.

Once you've decided that you are going to go for Swift 6 and did you want to level up your team's concurrency skills make sure you actually give everybody on the team a chance to properly learn!

Migrating from the outside in

Once you've started refactoring your packages and it's time to start working on your app target I found that it really makes sense to migrate from the outside in. You could also work from the inside out and in the end, it really depends on where you want to start. That said, I often like to start in the view layer once all the back-end stuff is done because it helps me determine at which point in the app I want to leave the main actor (or when yo apply a main actor annotation to stay on the main actor).

For example, if you’re using MVVM and you have a view model that holds a bunch of functions, where should these functions run?

This is where the work that you did up front comes into play because you already know that in the old way of doing things the view model would run its functions on the main thread. I would highly recommend that you do not change this. If your view model used to run on the main thread which is pretty much standard, keep it that way.

You'll want to apply a main actor annotation to your view model class.

This is not a bad thing by any means, it's not a hack either. It's a way for you to ensure that you're not switching isolation contexts all the time. You really don't need a ton of concurrency in your app code.

Apple is even considering introducing some language mode for Swift where everything is going to be on the main actor by default.

So for you to default your view models and maybe some other objects in your code base to the main actor simply makes a lot of sense. Once you start migrating like this you'll figure out that you really didn't need that much concurrency which you already should know because that's what you figured out early on into process.

This is also where you start to encounter warnings that are related to sendability and isolation contexts. Once you start to see these warnings and errors, you decide that the model should or shouldn't be sendable depending on whether the switch of isolation context that’s causing the warning is expected.

You can solve sendability problems with actors. That said, making things actors is usually not what you're looking for especially when it's related to models.

However, when you’re dealing with a reference type that has mutable state, that's where you might introduce actors. It’s all about figuring out whether you were expecting to use that type in multiple isolation contexts.

Having to deeply reason about every error and warning can sometimes feel tedious because it really slows you down. You can easily make something sendable, you could easily make something an actor, and it wouldn't impact your code that much. But you are introducing a lot of complexity into your codebase when you're introducing isolation contexts and when you're introducing concurrency.

So again, you really want to make sure that you limit the amount of concurrency in your app. You typically don't need a lot of concurrency inside an application. I can't stress this enough.

Pitfalls, caveats, and dangers

Migrating to Swift 6 definitely comes with its dangers and uncertainties. If you're migrating everything all at once, you're going to be embarking on a huge refactor that will involve touching almost every single object in your code. If you introduce actors where they really shouldn't belong, you suddenly have everything in your code becoming concurrent because interacting with actors is an asynchronous proces.

If you didn't follow the steps in this blog post, you're probably going to have asynchronous functions all over the place, and they might be members of classes or your view or anything else. Some of your async functions are going to be isolated to the main actor, but most of them will be non-isolated by default, which means that they can run anywhere. This also means that if you pass models or objects from your view to your few model to some other place that you're skipping isolation contexts all the time. Sometimes this is completely fine, and the compiler will figure out that things are actually safe, but in a lot of cases, the compiler is going to complain about this, and you will be very frustrated about this because you have no idea what's wrong.

There's also the matter of interacting with Apple's code. Not all of Apple's code is necessarily Swift 6 compatible or Swift 6 friendly. So you might find yourself having to write workarounds for interacting with things like a CLLocationManagerDelegate or other objects that come from Apple's frameworks. Sometimes it's trivial to know what to do once you fully understand how isolation works, but a lot of the times you're going to be left guessing about what makes the most sense.

This is simply unavoidable, and we need Apple to work on their code and their annotations to make sure that we can adopt Swift 6 with full confidence.

At the same time, Apple is looking at Swift as a language and figuring out that Swift 6 is really not in the place where they want it to be for general adoption.

If you're adopting Swift 6 right now, there are some things that might change down the line. You have to be willing to deal with that. If you're not willing to deal with that, I would recommend that you go for strict concurrency and don't go all-in on Swift 6 because things might change down the line and you don't want to be doing a ton of work that turns out to be obsolete. A couple versions of Swift down the line, and we're probably talking months, not years, before this happens.

In Summary

Overall, I think adopting Swift 6 is a huge undertaking for most teams. If you haven't started already and you're about to start now, I would urge you to take it slow - take it easy and make sure that you understand what you're doing as much as possible every step of the way.

Swift concurrency is pretty complicated, and Apple is still actively working on improving and changing it because they're still learning about things that are causing problems for people all the time. So for that reason, I'm not even sure that migrating to Swift 6 should be one of your primary goals at this point in time.

Understanding everything around Swift 6 I think is extremely useful because it does help you to write better and safer code. However, I do believe that sticking with the Swift 5 language mode and going for strict concurrency is probably your safest bet because it allows you to write code that may not be fully Swift 6 compliant but works completely fine (at least you can still compile your project even if you have a whole bunch of warnings).

I would love to know your thoughts and progress on migrating to Swift 6. In my workshops I always hear really cool stories about companies that are working on their migration and so if you have stories about your migration and your journey with Swift 6, I would love to hear that.

What’s new in Swift 6.1?

The Xcode 16.3 beta is out, which includes a new version of Swift. Swift 6.1 is a relatively small release that comes with bug fixes, quality of life improvements, and some features. In this post, I’d like to explore two of the new features that come with Swift 6.1. One that you can start using immediately, and one that you can opt-in on if it makes sense for you.

The features I’d like to explore are the following:

  1. Changes to Task Groups in Swift 6.1
  2. Changes to member visibility for imported code

We’ll start by looking at the changes in Concurrency’s TaskGroup and we’ll cover member visibility after.

Swift 6.1 and TaskGroup

There have been a couple of changes to concurrency in Swift 6.1. These were mainly small bug fixes and improvements but one improvement stood out to me and that’s the changes that are made to TaskGroup. If you're not familiar with task groups, go ahead and read up on them on my blog post right here.

Normally, a TaskGroup is created as shown below where we create a task group and specify the type of value that every child task is going to produce:

await withTaskGroup(of: Int.self) { group in
  for _ in 1...10 {
    group.addTask {
      return Int.random(in: 1...10)
    }
  }
}

Starting in Swift 6.1, Apple has made it so that we no longer have to explicitly define the return type for our child tasks. Instead, Swift can infer the return type of child tasks based on the first task that we add to the group.

That means that the compiler will useaddGroup it finds to determine the return type for all your child tasks.

In practice, that means that the code below is the equivalent of what we saw earlier:

await withTaskGroup { group in
  for _ in 1...10 {
    group.addTask {
      return Int.random(in: 1...10)
    }
  }
}

Now, as you might expect, this doesn't change the fact that our task groups have to return the same type for every child task.

The code above shows you how you can use this new return type inference in Swift 6.1. If you accidentally do end up with different return types for your child task like the code below shows, the compiler will present us with an error that will tell you that the return type of your call to addTask is incorrect.

await withTaskGroup { group in
  for _ in 1...10 {
    group.addTask {
      return Int.random(in: 1...10)
    }
  }

  group.addTask {
    // Cannot convert value of type 'String' to closure result type 'Int'
    return "Hello, world"
  }
}

Now, if you find that you do want to have multiple return types, I have a blog post on that. That approach still works. We can still use an enum as a return type for our task group for our child tasks, and that definitely still is a valid way to have multiple return types in a task group.

I’m quite happy with this change because having to specify the return type for my child tasks always felt a little tedious so it’s great to see the compiler take this job in Swift 6.1.

Next, let’s take a look at the changes to imported member visibility in Swift 6.1.

Imported member visibility in Swift 6.1

In Swift, we have the ability to add extensions to types to enhance or augment functionality that we already have. For example, you could add an extension to an Int to represent it as a currency string or something similar.

If I'm building an app where I'm dealing with currencies and purchases and handling money, I might have two packages that are imported by my app. Both packages could be dealing with currencies in some way shape or form and I might have an extension on Int that returns a String which is a currency string as I mentioned earlier.

Here's what that could look like.

// CurrencyKit
func price() -> String {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency
    formatter.locale = Locale.current

    let amount = Double(self) / 100.0 // Assuming the integer represents cents
    return formatter.string(from: NSNumber(value: amount)) ?? "$\(amount)"
}

// PurchaseParser
func price() -> String {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency
    formatter.locale = Locale.current

    let amount = Double(self) / 100.0 // Assuming the integer represents cents
    return formatter.string(from: NSNumber(value: amount)) ?? "$\(amount)"
}

The extension shown above exists in both of my packages, and the return types of these extensions are the exact same (i.e., strings). This means that I can have the following two files in my app, and it's going to be just fine.

// FileOne.swift
import PurchaseParser

func dealsWithPurchase() {
    let amount = 1000
    let purchaseString = amount.price()
    print(purchaseString)
}

// FileTwo.swift
import CurrencyKit

func dealsWithCurrency() {
    let amount = 1000
    let currencyString = amount.price()
    print(currencyString)
}

The compiler will know how to figure out which version of price should be used based on the import in my files and things will work just fine.

However, if I have two extensions on integer with the same function name but different return types, the compiler might actually get confused about which version of the extension I intended to use.

Consider the following changes to PurchaseParser's price method:

func price() -> Double {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency
    formatter.locale = Locale.current

    let amount = Double(self) / 100.0 // Assuming the integer represents cents
    return amount
}

Now, price returns a Double instead of a String. In my app code, I am able to use this extension from any file, even if that file doesn’t explicitly import PurchaseParser. As a result, the compiler isn’t sure what I mean when I write the following code in either of the two files that you saw earlier:

let amount = 1000
let currencyString = amount.price()

Am I expecting currencyString to be a String or am I expecting it to be a Double?

To help the compiler, I can explicitly type currencyString as follows:

let amount = 1000
let currencyString: String = amount.price()

This will tell the compiler which version of price should be used, and my code will work again. However, it’s kind of strange in a way that the compiler is using an extension on Int that’s defined in a module that I didn’t even import in this specific file.

In Swift 6.1, we can opt into a new member visibility mode. This member visibility mode is going to work a little bit more like you might expect.

When I import a specific module like CurrencyKit, I'm only going to be using extensions that were defined on CurrencyKit. This means that in a file that only imports CurrencyKit I won’t be able to use extensions defined in other packages unless I also import those. As a result, the compiler won’t be confused about having multiple extensions with the method name anymore since it can’t see what I don’t import.

Opting into this feature can be done by passing the corresponding feature flag to your package, here's what that looks like when you’re in a Swift package:

.executableTarget(
    name: "AppTarget",
    dependencies: [
        "CurrencyKit",
        "PurchaseParser"
    ],
    swiftSettings: [
        .enableExperimentalFeature("MemberImportVisibility")
    ]
),

In Xcode this can be done by passing the feature to the “Other Swift Flags” setting in your project settings. In this post I explain exactly how to do that.

While I absolutely love this feature, and I think it's a really good change in Swift, it does not solve a problem that I've had frequently. However, I can definitely imagine myself having that problem, so I'm glad that there's now a fix for that that we can opt into. Hopefully, this will eventually become a default in Swift.

In Summary

Overall, Swift 6.1 is a pretty lightweight release, and it has some nice improvements that I think really help the language be better than it was before.

What are your thoughts on these changes in Swift 6.1, and do you think that they will impact your work in any way at all?

Why you should keep your git commits small and meaningful

When you're using Git for version control, you're already doing something great for your codebase: maintaining a clear history of changes at every point in time. This helps you rewind to a stable state, track how your code has evolved, and experiment with new ideas without fully committing to them right away.

However, for many developers, Git is just another tool they have to use for work. They write a lot of code, make commits, and push their changes without giving much thought to how their commits are structured, how big their branches are, or whether their commit history is actually useful.

Why Commit Hygiene Matters

As projects grow in complexity and as you gain experience, you'll start seeing commits as more than just a step in pushing your work to GitHub. Instead, commits become checkpoints—snapshots of your project at specific moments. Ideally, every commit represents a logical stopping point where the project still compiles and functions correctly, even if a feature isn’t fully implemented. This way, you always have a reliable fallback when exploring new ideas or debugging issues.

Now, I’ll be honest—I’m not always perfect with my Git hygiene. Sometimes, I get deep into coding, and before I realize it, I should have committed ages ago. When working on something significant, I try to stage my work in logical steps so that I still have small, meaningful commits. If you don’t do this, the consequences can be frustrating—especially for your teammates.

The Pain of Messy Commits

Imagine you're debugging an issue, and you pinpoint that something broke between two commits. You start looking at the commit history and find something like:

  • wip (Work in Progress)
  • fixing things
  • more updates

None of these tell you what actually changed. Worse, if those commits introduce large, sweeping changes across the codebase, you’re left untangling a mess instead of getting helpful insights from Git’s history.

How Small Should Commits Be?

A good rule of thumb: your commits should be small but meaningful. A commit doesn’t need to represent a finished feature, but it should be a logical step forward. Typically, this means:

  • The project still builds (even if the feature is incomplete).
  • The commit has a clear purpose (e.g., “Refactor JSON parsing to use Decodable”).
  • If you’re adding a function, consider adding its corresponding test in the same commit.

For example, let’s say you’re refactoring JSON parsing to use Decodable and updating your networking client:

  1. Commit 1: Add the new function to the networking client.
  2. Commit 2: Add test scaffolding (empty test functions and necessary files).
  3. Commit 3: Write the actual test.
  4. Commit 4: Implement the feature.
  5. Commit 5: Rename a model or refactor unrelated code (instead of bundling this into Commit 4).

By structuring commits this way, you create a clear and understandable history. If a teammate needs to do something similar, they can look at your commits and follow your process step by step.

The Balance Between Clean Commits and Productivity

While good commit hygiene is important, don’t obsess over it. Some developers spend as much time massaging their Git history as they do writing actual code. Instead, strive for a balance: keep your commits clean and structured, but don’t let perfectionism slow you down.

You really don’t have to pick apart your changes just so you can have the cleanest commits ever. For example, if you’ve fixed a typo in a file that you were working on, you don’t have to make a separate commit for that if it means having to stage individual lines in a file.

On the other hand, if fixing that typo meant you also changed a handful of other files, you might want to put some extra work into splitting that commit up.

Commit Messages: Crafting a Meaningful Story

In addition to the size of your commits, your commit messages also matter. A good commit message should be concise but informative. Instead of vague messages like fix or updated code, consider something more descriptive, like:

  • Refactored JSON parsing to use Decodable
  • Fixed memory leak in caching logic
  • Added unit test for network error handling

By keeping your commit messages clear, you help yourself and others understand the progression of changes without having to dig into the code.

Rewriting Git History When Necessary

Sometimes, you may want to clean up your Git history before merging a branch. This is where tools like interactive rebase come in handy. Using git rebase -i HEAD~n, you can:

  • Squash multiple small commits into one.
  • Edit commit messages.
  • Reorder commits for better readability.

However, be cautious when rewriting history—once commits are pushed to a shared branch, rebasing can cause conflicts for your teammates.

Rebasing on the command line can be tricky but luckily most GUIs will have ways to perform interactive rebasing too. I personally use interactive rebasing a lot since I like rebasing my branches on main instead of merging main into my features. Merge commits aren’t that useful to have in my opinion and rebasing allows me to avoid them.

In Summary

In the end, it’s all about making sure that you end up having a paper trail that makes sense and that you have a paper trail that can actually be useful when you find yourself digging through history to see what you did and why.

The reality is, you won’t do this often. But when you do, you’ll feel glad that you took the time to keep your commits lean. By keeping your commits small, writing meaningful messages, and leveraging Git’s powerful tools, you ensure that your version control history remains a valuable resource rather than a tangled mess.

Observing properties on an @Observable class outside of SwiftUI views

On iOS 17 and newer, you have access to the Observable macro. This macro can be applied to classes, and it allows SwiftUI to officially observe properties on an observable class. If you want to learn more about Observable or if you're looking for an introduction, definitely go ahead and check out my introduction to @Observable in SwiftUI.

In this post, I would like to explore how you can observe properties on an observable class. While the ObservableObject protocol allowed us to easily observe published properties, we don't have something like that with Observable. However, that doesn't mean we cannot observe observable properties.

A simple observation example

The Observable macro was built to lean into a function called WithObservationTracking. The WithObservationTracking function allows you to access state on your observable. The observable will then track the properties that you've accessed inside of that closure. If any of the properties that you've tried to access change, there's a closure that gets called. Here's what that looks like.

@Observable
class Counter {
  var count = 0
}

class CounterObserver {
  let counter: Counter

  init(counter: Counter) {
    self.counter = counter
  }

  func observe() {
    withObservationTracking { 
      print("counter.count: \(counter.count)")
    } onChange: {
      self.observe()
    }
  }
}

In the observe function that’s defined on CounterObserver, I access a property on the counter object.

The way observation works is that any properties that I access inside of that first closure will be marked as properties that I'm interested in. So if any of those properties change, in this case there's only one, the onChange closure will be called to inform you that there have been changes made to one or more properties that you've accessed in the first closure.

How withObservationTracking can cause issues

While this looks simple enough, there are actually a few frustrating hiccups to deal with when you work with observation tracking. Note that in my onChange I call self.observe().

This is because withObservationTracking only calls the onChange closure once. So once the closure is called, you don’t get notified about any new updates. So I need to call observe again to once more access properties that I'm interested in, and then have my onChange fire again when the properties change.

The pattern here essentially is to make use of the state you’re observing in that first closure.

For example, if you're observing a String and you want to perform a search action when the text changes, you would do that inside of withObservationTracking's first closure. Then when changes occur, you can re-subscribe from the onChange closure.

While all of this is not great, the worst part is that onChange is called with willSet semantics.

This means that the onChange closure is called before the properties you’re interested in have changed so you're going to always have access to the old value of a property and not the new one.

You could work around this by calling observe from a call to DispatchQueue.main.async.

Getting didSet semantics when using withObservationTracking

Since onChange is called before the properties we’re interested in have updated we need to postpone our work to the next runloop if we want to get access to new values. A common way to do this is by using DispatchQueue.main.async:

func observe() {
  withObservationTracking { 
    print("counter.count: \(counter.count)")
  } onChange: {
    DispatchQueue.main.async {
      self.observe()
    }
  }
}

The above isn’t pretty, but it works. Using an approach based on what’s shown here on the Swift forums, we can move this code into a helper function to reduce boilerplate:

public func withObservationTracking(execute: @Sendable @escaping () -> Void) {
    Observation.withObservationTracking {
        execute()
    } onChange: {
        DispatchQueue.main.async {
            withObservationTracking(execute: execute)
        }
    }
}

The usage of this function inside of observe() would look as follows:

func observe() {
  withObservationTracking { [weak self] in
    guard let self else { return }
    print("counter.count: \(counter.count)")
  }
}

With this simple wrapper that we wrote, we can now pass a single closure to withObservationTracking. Any properties that we've accessed inside of that closure are now automatically observed for changes, and our closure will keep running every time one of these properties change. Because we are capturing self weakly and we only access any properties when self is still around, we also support some form of cancellation.

Note that my approach is rather different from what's shown on the Swift forums. It's inspired by what's shown there, but the implementation shown on the forum actually doesn't support any form of cancellation. I figured that adding a little bit of support for cancellation was better than adding no support at all.

Observation and Swift 6

While the above works pretty decent for Swift 5 packages, if you try to use this inside of a Swift 6 codebase, you'll actually run into some issues... As soon as you turn on the Swift 6 language mode you’ll find the following error:

func observe() {
  withObservationTracking { [weak self] in
    guard let self else { return }
    // Capture of 'self' with non-sendable type 'CounterObserver?' in a `@Sendable` closure
    print("counter.count: \(counter.count)")
  }
}

The error message you’re seeing here tells you that withObservationTracking wants us to pass an @Sendable closure which means we can’t capture non-Sendable state (read this post for an in-depth explanation of that error). We can’t change the closure to be non-Sendable because we’re using it in the onChange closure of the official withObservationTracking and as you might have guessed; onChange requires our closure to be sendable.

In a lot of cases we’re able to make self Sendable by annotating it with @MainActor so the object always runs its property access and functions on the main actor. Sometimes this isn’t a bad idea at all, but when we try and apply it on our example we receive the following error:

@MainActor
class CounterObserver {
  let counter: Counter

  init(counter: Counter) {
    self.counter = counter
  }

  func observe() {
    withObservationTracking { [weak self] in
      guard let self else { return }
      // Main actor-isolated property 'counter' can not be referenced from a Sendable closure
      print("counter.count: \(counter.count)")
    }
  }
}

We can make our code compile by wrapping access in a Task that also runs on the main actor but the result of doing that is that we’d asynchronously access our counter and we’ll drop incoming events.

Sadly, I haven’t found a solution to using Observation with Swift 6 in this manner without leveraging @unchecked Sendable since we can’t make CounterObserver conform to Sendable since the @Observable class we’re accessing can’t be made Sendable itself (it has mutable state).

In Summary

While Observation works fantastic for SwiftUI apps, there’s a lot of work to be done for it to be usable from other places. Overall I think Combine’s publishers (and @Published in particular) provide a more usable way to subscribe to changes on a specific property; especially when you want to use the Swift 6 language mode.

I hope this post has shown you some options for using Observation, and that it has shed some light on the issues you might encounter (and how you can work around them).

If you’re using withObservationTracking successfully in a Swift 6 app or package, I’d love to hear from you.

Solving “Main actor-isolated property can not be referenced from a Sendable closure” in Swift

When you turn on strict concurrency checking or you start using the Swift 6 language mode, there will be situations where you run into an error that looks a little bit like the following:

Main actor-isolated property can not be referenced from a Sendable closure

What this error tells us is that we're trying to use something that we're only supposed to use on or from the main actor inside of a closure that's supposed to run pretty much anywhere. So that could be on the main actor or it could be somewhere else.

The following code is an example of code that we could have that results in this error:

@MainActor
class ErrorExample {
  var count = 0

  func useCount() {
    runClosure {
      print(count)
    }
  }

  func runClosure(_ closure: @Sendable () -> Void) {
    closure()
  }
}

Of course, this example is very contrived. You wouldn't actually write code like this, but it is not unlikely that you would want to use a main actor isolated property in a closure that is sendable inside of a larger system. So, what can we do to fix this problem?

The answer, unfortunately, is not super straightforward because the fix will depend on how much control we have over this sendable closure.

Fixing the error when you own all the code

If we completely own this code, we could actually change the function that takes the closure to become an asynchronous function that can actually await access to the count property. Here's what that would look like:

func useCount() {
  runClosure {
    await print(count)
  }
}

func runClosure(_ closure: @Sendable @escaping () async -> Void) {
  Task {
    await closure()
  }
}

By making the closure asynchronous, we can now await our access to count, which is a valid way to interact with a main actor isolated property from a different isolation context. However, this might not be the solution that you're looking for. You might not want this closure to be async, for example. In that case, if you own the codebase, you could @MainActor annotate the closure. Here's what that looks like:

@MainActor
class ErrorExample {
  var count = 0

  func useCount() {
    runClosure {
      print(count)
    }
  }

  func runClosure(_ closure: @Sendable @MainActor () -> Void) {
    closure()
  }
}

Because the closure is now both @Sendable and isolated to the main actor, we're free to run it and access any other main actor isolated state inside of the closure that's passed to runClosure. At this point count is main actor isolated due to its containing type being main actor isolated, runClosure itself is main actor isolated due to its unclosing type being main actor isolated, and the closure itself is now also main actor isolated because we added an explicit annotation to it.

Of course this only works when you want this closure to run on the main actor and if you fully control the code.

If you don't want the closure to run on the main actor and you own the code, the previous solution would work for you.

Now let's take a look at what this looks like if you don't own the function that takes this sendable closure. In other words, we're not allowed to modify the runClosure function, but we still need to make this project compile.

Fixing the error without modifying the receiving function

When we're only allowed to make changes to the code that we own, which in this case would be the useCount function, things get a little bit trickier. One approach could be to kick off an asynchronous task inside of the closure and it'll work with count there. Here's what this looks like:

func useCount() {
  runClosure {
    Task {
      await print(count)
    }
  }
}

While this works, it does introduce concurrency into a system where you might not want to have any concurrency. In this case, we are only reading the count property, so what we could actually do is capture count in the closure's capture list so that we access the captured value rather than the main actor isolated value. Here is what that looks like.

func useCount() {
  runClosure { [count] in
    print(count)
  }
}

This works because we're capturing the value of count when the closure is created, rather than trying to read it from inside of our sendable closure. For read-only access, this is a solid solution that will work well for you. However, we could complicate this a little bit and try to mutate count which poses a new problem since we're only allowed to mutate count from inside of the main actor:

func useCount() {
  runClosure {
    // Main actor-isolated property 'count' can not be mutated from a Sendable closure
    count += 1
  }
}

We're now running into the following error:

Main actor-isolated property 'count' can not be mutated from a Sendable closure

I have dedicated post about running work on the main actor where I explore several ways to solve this specific error.

Out of the three solutions proposed in that post, the only one that would work for us is the following:

Use MainActor.run or an unstructured task to mutate the value from the main actor

Since our closure isn't async already, we can't use MainActor.run because that's an async function that we'd have to await.

Similar to how you would use DispatchQueue.main.async in old code, in your new code you can use Task { @MainActor in } to run work on the main actor:

func useCount() {
  runClosure { 
    Task { @MainActor in
      count += 1
    }
  }
}

The fact that we're forced to introduce a synchronicity here is not something that I like a lot. However, it is an effect of using actors in Swift concurrency. Once you start introducing actors into your codebase, you also introduce a synchronicity because you can synchronously interact with actors from multiple isolation contexts. An actor always needs to have its state and functions awaited when you access it from outside of the actor. The same applies when you isolate something to the main actor because when you isolate something to the main actor it essentially becomes part of the main actor's isolation context, and we have to asynchronously interact with main actor isolated state from outside of the main actor.

I hope this post gave you some insights into how you can fix errors related to capturing main actor isolated state in a sendable closure. If you're running into scenarios where none of the solutions shown here are relevant I'd love if you could share them with me.

Is 2025 the year to fully adopt Swift 6?

When Apple released Xcode 16 last year, they made the Swift 6 compiler available along with it. This means that we can create new projects using Swift 6 and its compile-time data race protections.

However, the big question for many developers is: Is 2025 the right time to adopt Swift 6 fully, or should we stick with Swift 5 for now?

In this post, I won’t give you a definitive answer. Instead, I’ll share my perspective and reasoning to help you decide whether adopting Swift 6 is right for you and your project(s).

The right answer depends on loads of variables like the project you work on, the team you work with, and your knowledge of Swift Concurrency.

Xcode 16, existing projects, and Swift 6

If you’ve opened an existing project in Xcode 16, you might not have noticed any immediate changes. While the Swift 6 compiler is used in Xcode 16 for all projects, Xcode defaults to the Swift 5 language mode for existing projects.

If you’ve experienced previous major migrations in Swift, you’ll remember that Xcode would usually prompt you to make changes to your project in order to make sure your project still works. This happened for the migration from Swift 1.2 to Swift 2, and from Swift 2 to Swift 3.

We got a new compiler, and we were forced to adopt the new Swift language version that came along with it.

Since then, the compiler has gained some “language modes”, and the Swift 6 compiler comes with a Swift 5 language mode.

The Swift 5 language mode allows the Swift 6 compiler to function without enforcing all the stricter rules of Swift 6. For example, the Swift 5 language mode will make it so that compile-time data race protections are not turned on.

So, when we talk about adopting Swift 6, we’re really talking about opting into the Swift 6 language mode.

Existing projects that are opened in Xcode 16 will, automatically, use the Swift 5 language mode. That’s why your project still compiles perfectly fine without adopting Swift 6.

What about new projects?

New projects in Xcode 16 also default to Swift 5 language mode. However, Swift packages created with the Swift 6 toolchain default to Swift 6 language mode unless explicitly configured otherwise. This distinction is important, because when you create new packages you’re operating in a different language mode than project (and that’s perfectly fine).

If you’re interested in enabling the Swift 6 language mode for existing projects or packages, I have some blog posts about that here:

Challenges of Adopting Swift 6

Switching to Swift 6 language mode can make projects that compiled just fine with Swift 5 break completely. For example, you’ll run into errors about capturing non-sendable parameters, sendable closures, and actor isolation.

Some fixes are straightforward—like making an immutable object explicitly sendable or refactoring objects that are used in async functions into actors. However, other issues, especially those involving crossing isolation boundaries, can be much trickier to fix.

For example, adding actors to resolve sendability errors often requires refactoring synchronous code into asynchronous code, leading to a ripple effect throughout your codebase. Even seemingly simple interactions with an actor require await, even for non-async functions because actors operate in their own isolation contexts.

Adopting actors is typically a task that will take much, much longer than you might expect initially.

Resolving errors with @MainActor

A common workaround is to liberally apply @MainActor annotations. While this reduces concurrency-related errors by forcing most code to run on the main thread, it’s not always the solution that you’re looking for. While not inherently wrong, this approach should be used with caution.

Reducing crossing of isolation boundaries

Apple recognizes the challenges of adopting Swift 6, especially for existing projects. One significant aspect of Swift Concurrency that can make adoption tricky is how non-isolated asynchronous functions inherit isolation contexts. Currently, nonisolated async functions run on a background thread unless explicitly isolated, which can lead to unnecessary crossing of isolation boundaries.

Apple is exploring ways for such functions to inherit the caller’s isolation context, potentially reducing sendability errors and making adoption of Swift 6 much more straightforward.

So, should we adopt Swift 6?

For existing projects, I recommend proceeding cautiously. Stick with Swift 5 language mode unless:

• Your project is small and manageable for migration.

• You have a strong understanding of concurrency concepts and can commit to resolving sendability issues.

New projects can be built with Swift 6 language mode from the start, but be prepared for challenges, especially when interacting with Apple’s frameworks, which may lack full concurrency support.

If you’re modularizing your codebase with Swift packages, I recommend using Swift 6 language mode for your (new) packages, as packages generally have fewer dependencies on Apple’s frameworks and are easier to adapt and you can have Swift 5 and Swift 6 modules in the same project.

Getting ready to adopt Swift 6

Before adopting Swift 6, ensure you understand:

• Sendability and how to resolve related errors.

• The use of actors and their impact on isolation and asynchronicity.

• How to navigate ambiguous compiler errors.

I cover all of these topics and more in my book, Practical Swift Concurrency as well as my workshops. You can also review and study Swift evolution proposals and forum discussions to get a good sense of how Swift Concurrency works.

If you’ve started adopting Swift 6 or decided to hold off, I’d love to hear your experiences! Connect with me on X, BlueSky, or Mastodon.

Sending vs Sendable in Swift

With Swift 6, we have an entirely new version of the language that has all kinds of data race protections built-in. Most of these protections were around with Swift 5 in one way or another and in Swift 6 they've refined, updated, improved, and expanded these features, making them mandatory. So in Swift 5 you could get away with certain things where in Swift 6 these are now compiler errors.

Swift 6 also introduces a bunch of new features, one of these is the sending keyword. Sending closely relates to Sendable, but they are pretty different in terms of why they're used, what they can do, and which problems they tend to solve.

In this post, I would like to explore the similarities and differences between Sendable and sending. By the end of this post, you will understand why the Swift team decided to change the closures that you pass to tasks, continuations, and task groups to be sending instead of @Sendable.

If you're not fully up to date on Sendable, I highly recommend that you check out my post on Sendable and @Sendable closures. In this post, it's most relevant for you to understand the @Sendable closures part because we're going to be looking at a comparison between a @Sendable closure and a sending argument.

Understanding the problem that’s solved by sending

In Swift 5, we didn't have the sending keyword. That meant that if we wanted to pass a closure or a value from one place to another safely, we would do that with the sendable annotation. So, for example, Task would have been defined a little bit like this in Swift 5.

public init(
  priority: TaskPriority? = nil,
  operation: @Sendable @escaping () async -> Success
)

This initializer is copied from the Swift repository with some annotations stripped for simplicity.

Notice that the operation argument takes a @Sendable closure.

Taking a @Sendable closure for something like a Task means that that closure should be safe to call from any other tasks or isolation context. In practice, this means that whatever we do and capture inside of that closure must be safe, or in other words, it must be Sendable.

So, a @Sendable closure can essentially only capture Sendable things.

This means that the code below is not safe according to the Swift 5.10 compiler with strict concurrency warnings enabled.

Note that running the example below in Xcode 16 with the Swift 6 compiler in Swift 5 mode will not throw any errors. That's because Task has changed its operation to be sending instead of @Sendable at a language level regardless of language mode.

So, even in Swift 5 language mode, Task takes a sending operation.

// The example below requires the Swift 5 COMPILER to fail
// Using the Swift 5 language mode is not enough
func exampleFunc() {
  let isNotSendable = MyClass()

  Task {
      // Capture of 'isNotSendable' with non-sendable type 'MyClass' in a `@Sendable` closure
    isNotSendable.count += 1
  }
}

If you want to explore this compiler error in a project that uses the Swift 6 compiler, you can define your own function that takes a @Sendable closure instead of a Task:

public func sendableClosure(
  _ closure: @Sendable () -> Void
  ) {
  closure()
}

If you call that instead of Task, you’ll see the compiler error mentioned earlier.

The compiler error is correct. We are taking something that isn't sendable and passing it into a task which in Swift 5 still took a @Sendable closure.

The compiler doesn't like that because the compiler says, "If this is a sendable closure, then it must be safe to call this from multiple isolation contexts, and if we're capturing a non-sendable class, that is not going to work."

This problem is something that you would run into occasionally, especially with @Sendable closures.

Our specific usage here is totally safe though. We're creating an instance of MyClass inside of the function that we're making a task or passing that instance of MyClass into the task.

And then we're never accessing it outside of the task or after we make the task anymore because by the end of exampleFunc this instance is no longer retained outside of the Task closure.

Because of this, there's no way that we're going to be passing isolation boundaries here; No other place than our Task has access to our instance anymore.

That’s where sending comes in…

Understanding sending arguments

In Swift 6, the team added a feature that allows us to tell the compiler that we intend to capture whatever non-sendable state we might receive and don't want to access it elsewhere after capturing it.

This allows us to pass non-sendable objects into a closure that needs to be safe to call across isolation contexts.

In Swift 6, the code below is perfectly valid:

func exampleFunc() async {
  let isNotSendable = MyClass()

  Task {
    isNotSendable.count += 1
  }
}

That’s because Task had its operation changed from being @Sendable to something that looks a bit as follows:

public init(
  priority: TaskPriority? = nil,
  operation: sending @escaping () async -> Success
)

Again, this is a simplified version of the actual initializer. The point is for you to see how they replaced @Sendable with sending.

Because the closure is now sending instead of @sendable, the compiler can check that this instance of MyClass that we're passing into the task is not accessed or used after the task captures it. So while the code above is valid, we can actually write something that is no longer valid.

For example:

func exampleFunc() async {
  let isNotSendable = MyClass()

  // Value of non-Sendable type ... accessed after being transferred; 
  // later accesses could race
  Task {
    isNotSendable.count += 1
  }

  // Access can happen concurrently
  print(isNotSendable.count)
} 

This change to the language allows us to pass non-sendable state into a Task, which is something that you'll sometimes want to do. It also makes sure that we're not doing things that are potentially unsafe, like accessing non-sendable state from multiple isolation contexts, which is what happens in the example above.

If you are defining your own functions that take closures that you want to be safe to call from multiple isolation contexts, you’ll want to mark them as sending.

Defining your own function that takes a sending closure looks as follows:

public func sendingClosure(
  _ closure: sending () -> Void
) {
  closure()
}

The sending keyword is added as a prefix to the closure type, similar to where @escaping would normally go.

In Summary

You probably won't be defining your own sending closures or your own functions that take sending arguments frequently. The Swift team has updated the initializers for tasks, detached tasks, the continuation APIs, and the task group APIs to take sending closures instead of @Sendable closures. Because of this, you'll find that Swift 6 allows you to do certain things that Swift 5 wouldn't allow you to do with strict concurrency enabled.

I think it is really cool to know and understand how sending and @Sendable work.

I highly recommend that you experiment with the examples in this blog post by defining your own sending and @Sendable closures and seeing how each can be called and how you can call them from multiple tasks. It's also worth exploring how and when each options stops working so you're aware of their limitations.

Further reading