How to list big models cheaply: Vein vs SwiftData

September 4, 2026

Recently Xu Yang (aka Fatbobman, iOS Developer & Blogger) published an article called "SwiftData: Optimization Starts with Modeling". In this article he shared data gathered using a benchmark he wrote. Naturally I got curious how Vein would perform in the same benchmark. He was so nice to share it with me.

I recommend reading his article first, but it's not necessary to understand mine.

What all this is about:

Once a dataset grows, SwiftData populated Lists start to stutter. This is only partially due SwiftUI. The enormous memory footprint as well as initial fetch times matter a lot. SwiftData populates every field on creation of the model instance with no way to only load certain ones on demand.

A common way to write models:

@Model
final class Note {
    var title: String
    var createdAt: Date
    var isPinned: Bool
    var body: String          // only needed in a detail view
    var attachment: Data      // only needed in a detail view
}
Model from Xu Yang's article

How to solve that in SwiftData?

As he outlined in his article, relationships are lazy loaded. So you can solve this in SwiftData, by splitting up the models, moving large fields to separate models.

@Model
final class Note {
    var title: String
    var createdAt: Date
    var isPinned: Bool
    var preview: String?                 // summary for the list, deliberately duplicated
    var body: NoteBody?
    var attachment: NoteAttachment?
}

@Model
final class NoteBody {
    var text: String
    var note: Note?
}

@Model
final class NoteAttachment {
    var data: Data // thumbnail
    var original: AttachmentOriginal?
    var note: Note?
}

@Model
final class AttachmentOriginal {
    @Attribute(.externalStorage)
    var data: Data // full-size image
    var attachment: NoteAttachment?
}
Split up Model from Xu Yang's article

How about Vein?

Vein is designed to be similar to SwiftData. I really like how easy it is to use. But there are issues, like the one I just described. Vein gives you more control - if you want it. By default Vein behaves very similar to SwiftData: every field is eager loaded, relationships are lazy. You don't even need to have encountered an issue with this during your own use, it's pretty logical that loading large chunks of data that you don't even need a lot of the time is generally not the best idea. Doing it correctly is actually very easy in Vein. You just slap @LazyField on a property you only want to load on access, and it happens.

@Model
final class Note {
    var title: String
    var createdAt: Date
    var isPinned: Bool
    @LazyField
    var body: String          // only needed in a detail view
    @LazyField
    var attachment: Data      // only needed in a detail view
}

The numbers

The benchmark creates 3000 records:

  • title: ~48 bytes
  • body: 8KB
  • attachment: 32KB

Each run uses a fresh model with cleared in memory cache. Seeding is not measured. I will mostly not go into memory usage as I only have data about the deltas of memory usage, not the isolated operations and the delta is never negative. All numbers are from the same run on my M3 Pro and should only be compared to each other, not Xu Yang's results due to potentially different testing machines. These numbers are the result of only one test run and therefore shouldn't be taken as absolutes. They only serve to demonstrate that we achieve our goal.

For Vein commit d0ba408 was used.


Fetching all rows, touching nothing:

Framework

Model Version

Duration

SwiftData

original

0.312s

Vein

original

0.110s

Vein

with @LazyField

0.070s

Fetching all rows, touching only title:

Framework

Model Version

Duration

SwiftData

original

0.326s

SwiftData

split up

0.035s

Vein

original

0.100s

Vein

with @LazyField

0.070s

Vein

split up

0.065s

We can clearly see both LazyField and splitting up doing what we intended: improving performance (and memory footprint is significantly smaller, by over 100MB for both SwiftData and Vein on the optimized versions).

Fetching all rows, touching everything:

Framework

Model Version

Duration

SwiftData

original

0.559s

SwiftData

split up

0.472s

Vein

original

0.130s

Vein

with @LazyField

0.336s

Vein

split up*

0.321s

*split up, keeping a strong reference to the inner model while touching, Vein does't keep one by default. Not doing that results in 0.531s, as the inner model needs to be fetched multiple times.

I'm a bit irritated by SwiftData's original model being measurably slower than the optimized one here while Vein's optimized one gets slower. Vein's @LazyField solution being slow here is a classic N+1 query problem, or more accurately 2N+1 in this case, as each lazy field triggers its own fetch and there are two of them per row. While @LazyField saves time during initial loads, accessing every property sequentially forces the engine to make a round-trip to the db for every lazy field in every row. And one of Vein's biggest features is transparency. If I add predictive pre-fetching at some point, it will be explicitly opt in.

Conclusion

Optimizing is worth it. Vein provides a second, very easy, straightforward option to optimize using a builtin, intended way, instead of what feels more like a workaround with SwiftData.

What I take from this for Vein

Adding @LazyField was the right decision and it works as intended, saving RAM and time when larger data isn't needed. Baseline performance is already really good, way better than I expected and SwiftData is somehow significantly slower than Vein pre optimization. I'm very happy with this result. Either the Observation framework adds a huge amount of overhead on SwiftData, or my serialization is just plain a lot simpler.

The optimized Vein models not improving by as much as SwiftData makes me think there is a baseline amount of time spent on something that didn't go away by improving the model. I will put profiling this onto my todo list for 1.2.

Vein being so surprisingly fast when eager loading everything tells me that there should be a way to eager load @LazyField declared properties via a parameter on a fetch. That way you could keep the benefits for the UI without making potentially still existing cases where you need them on every row slower (no N+1, yay).


Thanks again to Xu Yang for providing the benchmark and encouraging me to write an article about it.

If you're interested in Vein please check it out and maybe leave a star: Amethyst Vein

You can find the full benchmark results here: SwiftData | Vein | Combined

Until next time ~ Mia