Jacob’s Tech Tavern

Jacob’s Tech Tavern

High Performance SwiftData Apps

My 3000-word apology for killing my iPhone processor

Jacob Bartlett's avatar
Jacob Bartlett
Jul 14, 2025

A few weeks ago, I released a new app to the world. Unfortunately, nobody seemed to like it very much. Let’s chalk it up to a rare Jacob Bartlett miss.

But there is an untold story behind the development of the app. A story with nothing to do with my outdated early 2000’s anime references.

A story of SwiftData gone wrong.

And a story of performance pain, of data integrity, and of a successful migration.

I took my nearly-complete app for a test-drive at London Comic-Con. The app took photos and converted them into Top-Trumps-style trading cards with customised names, relevant stat ratings, and different UI effects based on rarity.

I wanted to collect some cool Comic-Con costumes.

In product terms, the app was a massive success. The cosplayers looked great, the shader effects popped, and my client-hosted AI model was handing out accurate names and stats.

But I had twin problems creeping up…

My full post-comic-con haul. Who needs merch when you have photos?

Every time I saved a new card, the app would get slower.

And slower.

And slower.

My battery dwindled rapidly.

I went into low-power mode.

And slower.

And hotter.

And it started crashing.

And got slower.

You get the idea.

Eventually I was waiting 20 seconds between taking a photo and being able to save the card to my collection. Then it would just crash every time.

Something was very wrong with the way I’d implemented SwiftData.

I got home. I knew I had to fix it.

But I also needed to keep my device safe, otherwise I’d lose my hard-earned Comic-Con collection.

So I had my 2 objectives:

  1. Optimise my SwiftData usage to make my app performant…

  2. …without losing all the existing cards I’ve collected

This post is pretty long, so it may be cut off in some email clients. Read the full article uninterrupted, on my website.

Read on my website


Diagnosing the Problem

When improving the performance of an app, I usually have a simple 3-step process.

  1. Test on a real device to identify user-facing problems.

  2. Profile the app using Instruments to identify bottlenecks.

  3. Implement code improvements using 1) and 2) as our guide.

This find-problems-first approach avoids bike-shedding, keeping the focus on the real bottlenecks.

But frankly, I don’t have to try very hard to identify the user-facing problem. Just look at this screen recording of me taking a photo of Mario:

That isn’t buffering, that’s a 15-second-long UI hang.

That isn’t Substack’s crappy video compression, those are massive scroll hitches.

Because I’m a good scientist, let’s connect the app to instruments and take another photo just to make sure. Uh, it crashes again.

Let’s see what was happening in the Xcode memory debugger.

When loading in my main screen, it’s black for a long while before displaying all the images at once in my LazyVStack. That’s a big spike.

The crash happened during the second big UI freeze, which happened when saving the photo. Memory gradually creeps up during the hang and eventually the system kills the app for being a resource hog.

Now, let’s check out the trace in instruments. We can clearly see the huge severe hangs on launch and saving a photo, up until the crash when recording stops.

Looking at the libraries called by the main thread, we see the full SwiftData-to-SQL abstraction pyramid:

  • libsqlite3.dylib

  • CoreData

  • SwiftData

It doesn’t take a genius to realise I’m doing something badly wrong with SwiftData.


Optimising SwiftData

Something was very wrong with my SwiftData approach.

  • First and foremost, it was performing all its work on the main thread. While we want to present our models in our UI, we don’t necessarily need to perform all the fetching work.

  • Secondly, I was making a big mistake with my data modelling. Somewhere, I was doing some incredibly heavy work; enough to push the system past its memory limits.

Let’s get started.


Backing up the container

I don’t want to mess this up.

I am scared to risk my beautiful comic-con snaps.

Therefore, before doing any work, I will back up my app container to my computer.

At 904.3MB, this is, to use the parlance of generation Z, a hefty heckin’ chonker. Look, I never said the MVP was optimised, okay?

If I did lose all my data, I can place this default.store into my Xcode project as a resource. I can then recover it in the app by copying it to the model file location in applicationSupportDirectory at launch.

Ok, I’m less scared now. Back to it.


Moving off the main thread

The main culprit for our hangs is an unsuspecting property wrapper that lives on our main collection view:

Gist

Uh, hold on, it’s not a property wrapper, it’s a macro. Who signed off on this syntax!?

Let’s take a look inside.

When the view appears, this query fetches the card data from SwiftData. It’s cached on the synthesised _cardEntities property.

That’s one of my big bugbears with SwiftData, frankly. It is designed by default to live on the main thread and be accessed via property wrappers, erm, I mean macros.

Fortunately, some devilishly handsome genius (no, not Antoine, I meant me) already found a way to use SwiftData outside the context of SwiftUI, unlocking the ability to move ourselves off the main thread.

How to use SwiftData outside SwiftUI

Jacob Bartlett
·
April 15, 2024
How to use SwiftData outside SwiftUI

Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.

Read full story

Let’s implement this technique.


Migrating our Reads to the view model

We can start by using my generic SwiftDatabase to set up a database that stores our card entities.

Gist

Now, we can just move all my cards into the view model instead of relying on the main-thread-locked @Query macro:

Gist

And now I can adjust my view to display these:

Gist

This tiny shift away from the SwiftUI-embedded macro into a view model gives us the ability to load everything off the main thread, and even parallelise if we want to.


Migrating our Writes to the view model

Before we cannonball on, however, we also need to update the logic for writing a new card.

Before, the logic lives in the SwiftUI view:

Gist

After, the save logic lives in the view model:

Gist

Hm, it doesn’t like this.

It crashes on writes, not reads, because SQLite fails to resolve the relationship between the CardEntity I’m saving and its Stats, which is a separate @Model. We can solve this by explicitly defining their relationship.

Gist

I tested this on the simulator before I risked killing my beautiful comic con card collection with a dodgy data migration. (I know I have a backup, but who wants the hassle?)

But this worked!

I didn’t even need any manual migration step, because the underlying data didn’t change; only the way SQLite interprets the relationships between the stored data. This lightweight migration is done automatically without any extra work on our part.

Now, let’s profile the app again.

Already, a dramatic improvement: it loads in much faster, without any hangs!

I didn’t even have to manually place anything off the main thread — it appears that by placing it in the view model, SwiftData can intelligently perform the expensive fetching and decoding off-main, and then presents it in my LazyVStack.

The time profile shows how much cleaner this small change makes our app. It still has its problems, including several hangs and CPU spikes, but our app is at least, almost, functional.

Clearly, however, there is a long way to go to optimise our memory usage.

By the memory graph and time profile conveniently signpost us to the next bottleneck: png_read_IDAT_data.

Okay, now for the good part.

Subscribe for free to continue reading

Enter your email and get every post from Jacob’s Tech Tavern.
Already have an account? Sign in
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for writing, podcasting, and media founders.