Performance issues with SwiftUI + realm

Another Dev and I are building a SwiftUI app that utilizes Realm + MongoDB Atlas device sync.

We are experiencing some oddly slow page load times when we have a simple Vstack that contains maybe 20 items. With a very small amount of items the lag is barely noticeable but once we get higher it breaks down.

We had to stop using the SwiftUI wrapper / helper classes that Realm has available for a number of reasons, they seem to have not been fully thought through and don’t come with good examples. Removing these helped some of the issues.

It seems like SwiftUI probably doesn’t play nice with the way Realm is designed, the views are re-rendered a lot more than you would think and I’m currently assuming Realm is re-initializing data every time it’s called to re-render the view but I can’t confirm that.

Has anyone else had issues like this? Are there any tricks that you can share or some big fix that I haven’t found yet?

Does it work to convert realm objects into structs and wrap/unwrap them instead of calling the live objects every time?

Generally speaking, Realm is pretty darn fast; objects are stored locally and synced in the background so network lag isn’t an issue.

In our experience, performance issues are usually caused by bad code or incorrect implementation. Given the examples in the documentation could be better, they really do demonstrate the core of working with Realm.

I feel the question could be better addressed if you could provide a minimal example of the performance issues you’re experiencing. Keeping in mind a forum is not a good troubleshooting platform for long sections of code, if you could include some brief sample code that demonstrates those issues, maybe we’ll spot something.

I will continue to troubleshoot and will update this post here with what I discover.

But, I mostly posted the topic here in case anyone from the community has experience with SwiftUI + Realm and if they know of any common gotchas / tips / tricks for using the two together. I’m pretty sure someone else has run into similar behavior.

I don’t think there is anything wrong with Realm here, but there might be issues with SwiftUI not playing nice with it.

This post was useful but is more focused on MacOS: Performance issues with SwiftUI on macOS

Understood. We use Realm, Swift & SwiftUI on macOS daily and don’t have any significant performance issues. That being said, our implementation could be way different than yours and I am sure our use cases are different as well.

I can tell you that in some cases, if you’re copying Realm objects to an array, it will hinder performance. But if you’re not doing that then it doesn’t apply.

Are you using @Observed objects? How is that implemented into a VStack? Did you try LazyVStack to see if there was any difference?

Seeing some code would be useful to track down the issue so if you have time, share some.

A few updates. I’m relaying some of the info from another dev on the team, sorry if I don’t explain some of the details well.

We are using @ObservedResults / @ObservedRealmObject property wrapper, but what we found is that even though realm freezes these (they are immutable results that get replaced from updates), but the binding still returns new memory references every time the UI requests data.

So the new memory references seem to cause SwiftUI to have a lot of extra needless re-renders. No idea why the freeze/immutable behavior was designed like this but I can see this behavior not playing nice with reactive frameworks like SwiftUI. The way this should work with the frozen/immutable results is that you get the same memory reference with observed/data bindings unless something changed, that would likely totally fix the problem.

The extra needless re-renders appear to cause our slow loading performance issues, but I can imagine a lot of projects use a very simple implementation of VStack/LazyVStacks that might not have as much of a negative impact. Ours is a bit more on the complicated side.

We decided to test converting all fetched data to structs to side step the issue with constantly new memory references causing re-renders. We set it up in a new test app to verify and so far it appears to have completely removed the needless re-renders that we were seeing.

I “think” this is the strategy we are using for the struct conversions, but I can’t say for sure atm. CustomPersistable Protocol Reference

We haven’t fully finished trouble shooting this but I wanted to add a quick update.
If others aren’t experiencing this issue with Realm + SwiftUI all I can think is that their use cases in SwiftUI for the realm data must be very simple compared to ours.

You actually don’t really need any example project, as the behavior is in the Realm SwiftUI quickstart with some minor modifications just to display that it’s happening.

All I’ve modified here is adding a simple counter state to ItemsView and some debug printing that shows when/why things rerender.

import RealmSwift
import SwiftUI

// MARK: Models

/// Random adjectives for more interesting demo item names
let randomAdjectives = [
    "fluffy", "classy", "bumpy", "bizarre", "wiggly", "quick", "sudden",
    "acoustic", "smiling", "dispensable", "foreign", "shaky", "purple", "keen",
    "aberrant", "disastrous", "vague", "squealing", "ad hoc", "sweet"
]

/// Random noun for more interesting demo item names
let randomNouns = [
    "floor", "monitor", "hair tie", "puddle", "hair brush", "bread",
    "cinder block", "glass", "ring", "twister", "coasters", "fridge",
    "toe ring", "bracelet", "cabinet", "nail file", "plate", "lace",
    "cork", "mouse pad"
]

/// An individual item. Part of an `ItemGroup`.
final class Item: Object, ObjectKeyIdentifiable {
    /// The unique ID of the Item. `primaryKey: true` declares the
    /// _id member as the primary key to the realm.
    @Persisted(primaryKey: true) var _id: ObjectId

    /// The name of the Item, By default, a random name is generated.
    @Persisted var name = "\(randomAdjectives.randomElement()!) \(randomNouns.randomElement()!)"

    /// A flag indicating whether the user "favorited" the item.
    @Persisted var isFavorite = false

    /// Users can enter a description, which is an empty string by default
    @Persisted var itemDescription = ""
    
    /// The backlink to the `ItemGroup` this item is a part of.
    @Persisted(originProperty: "items") var group: LinkingObjects<ItemGroup>
    
}

/// Represents a collection of items.
final class ItemGroup: Object, ObjectKeyIdentifiable {
    /// The unique ID of the ItemGroup. `primaryKey: true` declares the
    /// _id member as the primary key to the realm.
    @Persisted(primaryKey: true) var _id: ObjectId

    /// The collection of Items in this group.
    @Persisted var items = RealmSwift.List<Item>()
    
}

extension Item {
    static let item1 = Item(value: ["name": "fluffy coasters", "isFavorite": false, "ownerId": "previewRealm"])
    static let item2 = Item(value: ["name": "sudden cinder block", "isFavorite": true, "ownerId": "previewRealm"])
    static let item3 = Item(value: ["name": "classy mouse pad", "isFavorite": false, "ownerId": "previewRealm"])
}

extension ItemGroup {
    static let itemGroup = ItemGroup(value: ["ownerId": "previewRealm"])
    
    static var previewRealm: Realm {
        var realm: Realm
        let identifier = "previewRealm"
        let config = Realm.Configuration(inMemoryIdentifier: identifier)
        do {
            realm = try Realm(configuration: config)
            // Check to see whether the in-memory realm already contains an ItemGroup.
            // If it does, we'll just return the existing realm.
            // If it doesn't, we'll add an ItemGroup and append the Items.
            let realmObjects = realm.objects(ItemGroup.self)
            if realmObjects.count == 1 {
                return realm
            } else {
                try realm.write {
                    realm.add(itemGroup)
                    itemGroup.items.append(objectsIn: [Item.item1, Item.item2, Item.item3])
                }
                return realm
            }
        } catch let error {
            fatalError("Can't bootstrap item data: \(error.localizedDescription)")
        }
    }
}

// MARK: Views

// MARK: Main Views
/// The main screen that determines whether to present the SyncContentView or the LocalOnlyContentView.
/// For now, it always displays the LocalOnlyContentView.
@main
struct ContentView: SwiftUI.App {
    var body: some Scene {
        WindowGroup {
            LocalOnlyContentView()
        }
    }
}

/// The main content view if not using Sync.
struct LocalOnlyContentView: View {
    @State var searchFilter: String = ""
    // Implicitly use the default realm's objects(ItemGroup.self)
    @ObservedResults(ItemGroup.self) var itemGroups
    
    var body: some View {
        if let itemGroup = itemGroups.first {
            // Pass the ItemGroup objects to a view further
            // down the hierarchy
            ItemsView(itemGroup: itemGroup)
        } else {
            // For this small app, we only want one itemGroup in the realm.
            // You can expand this app to support multiple itemGroups.
            // For now, if there is no itemGroup, add one here.
            ProgressView().onAppear {
                $itemGroups.append(ItemGroup())
            }
        }
    }
}

// MARK: Item Views
/// The screen containing a list of items in an ItemGroup. Implements functionality for adding, rearranging,
/// and deleting items in the ItemGroup.
struct ItemsView: View {
    @ObservedRealmObject var itemGroup: ItemGroup
    @State var counter = 0
    
    /// The button to be displayed on the top left.
    var leadingBarButton: AnyView?
    
    var body: some View {
        let _ = Self._printChanges()
        NavigationView {
            VStack {
                // The list shows the items in the realm.
                List {
                    ForEach(itemGroup.items) { item in
                        ItemRow(item: item)
                    }.onDelete(perform: $itemGroup.items.remove)
                    .onMove(perform: $itemGroup.items.move)
                }
                .listStyle(GroupedListStyle())
                    .navigationBarTitle("Items", displayMode: .large)
                    .navigationBarBackButtonHidden(true)
                    .navigationBarItems(
                        leading: self.leadingBarButton,
                        // Edit button on the right to enable rearranging items
                        trailing: EditButton())
                // Action bar at bottom contains Add button.
                HStack {
                    Spacer()
                    Button(action: {
                        // The bound collection automatically
                        // handles write transactions, so we can
                        // append directly to it.
                        $itemGroup.items.append(Item())
                    }) { Image(systemName: "plus") }
                }.padding()
                
                Button("Increment counter (\(counter))") {
                    counter += 1
                }
            }
        }
    }
}

struct ItemsView_Previews: PreviewProvider {
    static var previews: some View {
        let realm = ItemGroup.previewRealm
        let itemGroup = realm.objects(ItemGroup.self)
        ItemsView(itemGroup: itemGroup.first!)
    }
}

/// Represents an Item in a list.
struct ItemRow: View {
    @ObservedRealmObject var item: Item

    var body: some View {
        let _ = Self._printChanges()

        // You can click an item in the list to navigate to an edit details screen.
        NavigationLink(destination: ItemDetailsView(item: item)) {
            Text(item.name)
            if item.isFavorite {
                // If the user "favorited" the item, display a heart icon
                Image(systemName: "heart.fill")
            }
        }
    }
}

/// Represents a screen where you can edit the item's name.
struct ItemDetailsView: View {
    @ObservedRealmObject var item: Item

    var body: some View {
        VStack(alignment: .leading) {
            Text("Enter a new name:")
            // Accept a new name
            TextField("New name", text: $item.name)
                .navigationBarTitle(item.name)
                .navigationBarItems(trailing: Toggle(isOn: $item.isFavorite) {
                    Image(systemName: item.isFavorite ? "heart.fill" : "heart")
                })
        }.padding()
    }
}

struct ItemDetailsView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            ItemDetailsView(item: Item.item2)
        }
    }
}

To reproduce, run this, add an item, then click on the “Increment counter” button and check the log. You will see something like:

ItemsView: _counter changed.
ItemRow: @self changed.

ItemRow gets rerendered even though it did not (functionally) change at all. But, in actuality it’s a brand new observable object getting passed in to ItemRow.

A dev on github tested the memory address theory and said the same thing happens if you cache the items into an array, so that disproves the theory.

My current theory is just that when you pass a Realm object to a view, you’re always implicitly creating an observable object, and that is always going to cause the view to rerender.

If you do some extra work and only pass equivalent data as a struct, the problem goes away. But, obviously you lose a lot of benefits with this approach.

I think for simple apps using List views that use navigation to drill down to detail views, this likely isn’t much of a problem because List manages how many items are “mounted” at any given time. But imagine something like a kanban board desktop app using a lot of custom stuff instead of List, and you might be able to imagine it becoming a major problem. Rerendering a component far up on the hierarchy causes a huge cascade of rerendering.

-Jon

2 Likes

This is actually very accurate, I used to reproduce this behavior with the same means for a couple of years now, and avoid passing realm objects to a view, I started the struct approach years ago. This is very accurate and on point.

Also you can double check this by using TestFlight and comparing the re-renders that keep occurring on a page by page basis in the app. Readingminitial post, I’m glad I read through the rest of this as I was just about to suggest this.

Yes I find SwiftUI and RealmSwift don’t get along too well.

Just placing the cursor in a TextField that is bound to a Realm object property causes the whole user interface to lock up on macOS. For some reason it seems this is causing all UI objects to be re-rendered. Not sure this is a RealmSwift related issue or a SwiftUI issue.

I have two lists, one with around 30 realm objects and a second one with around 2000 objects (not realm objects).

The bound property in the TextField comes from one of the realm objects from the first list. Just placing the cursor in the TextField causes the colourful umbrella to show up for 20 or 30 seconds. Pretty strange.

EDIT:
LazyVStack {} seems to largely address the issue - which does not appear to be RealmSwift related. Seems SwiftUI will by default create all the list views unless LazyStack is used. !

That’s very concerning. One of the projects we use internally for testing is macOS, SwiftUI and displays a couple hundred realm objects in a list along with a few bound TextFields in the same UI.

We have not seen any slowdowns or other odd behavior. Not saying it isn’t there, we just dont notice them and are not experiencing a lock up.

Do you have some brief sample code that duplicates the behavior? Going forward, it would be helpful to know what to avoid doing!

We had the same issue and here’s what we’ve found. Sorry if this isn’t explained well.

  • SwiftUI does have a problem with re-rendering too much of the screen when any data changes. The new SwiftUI changes announced at WWDC should fix this problem moving forward with the new iOS version.

  • RealmSwift still has a fundamental problem that doesn’t play nice with SwiftUI. Realm returns new references/object references every time the code hits a realm object. And every re-render requests the realm data… This is a very fundamental flaw that needs to be fixed for realm to play nice with SwiftUI. If someone doesn’t see issues from this, they probably have a simple view/screen with little data being displayed. SwiftUI’s reactive behavior is not designed to work with data that changes every single time you ask for it. The issue compounds when SwiftUI is re-rendering way too much of the screen for small data changes.

It’s been a little while since I read about Realm’s freezing objects concept, which should remove this live object / new object issue, but it simply doesn’t do that when using Swift. I’m assuming this is a bug that hasn’t been addressed but it’s hard to say for sure.

  • We work around this realm new object issue by transforming all realm objects to structs before giving it to a view so it properly freezes the data when a view gets ahold of the data. And if the data changes, we replace the relevant struct.

  • Lastly, there is a problem with displaying a list of textfields in swiftUI. If 1 textfield grabs focus, SwiftUI re-renders the entire screen, basically. So that will cause very noticeable UI lag. We work around this by only displaying “text” objects in a list, and on tap we replace the text with a textfield so there is only 1 available at a time on a given list. I’m assuming this has been fixed in the new version of SwiftUI but I haven’t checked yet.

@Jay - it turns out this has nothing to do with RealmSwift, it was just co-incidental that the text field was one bound to a realm objects property.

Using LazyVStack or LazyHStack for a list containing a few thousand items makes the problem go away.

Apparently SwiftUI will render every object in the list (??) unless the Lazy* is used so any list with a few thousand items becomes problematic.

Not quite sure why it seems to render the entire list again when the cursor is placed in a TextField field.

I’ve found this problem to be SwiftUI itself. Any view with @FocusState will rerender any time there is a change in focus, whether or not it’s relevant to that view. Hopefully iOS 17 and macOS Sonoma will fix it. I haven’t looked into the latest focus changes for that yet.