2025-05-15

Realm with Swift 6 Async Await and Actor-Based Concurrency

Step-by-step guide to upgrading Realm for Swift 6 with thread-safe actors, async data access, and a clean DAO pattern — including live data observation with AsyncStream.


Realm and Swift 6 article banner

At the company where I work, we’re increasingly using async/await and aiming to migrate the project to Swift 6 to take advantage of the new concurrency checks. Migrating an entire codebase to Swift 6 all at once isn’t feasible for a project of this size and complexity.

The chosen strategy is to break down this monolith into smaller local SPM modules. Several modules have already been implemented, but since they are mostly UI-related, migrating them to Swift 6 had minimal impact. To deepen our understanding of the topic, we decided that the next module to migrate would be the Database module.

RealmSwiftLink icon

We’ve been using RealmSwift for data persistence since almost the beginning of the project. However, there are cases where the data doesn’t match expectations, or we try to access it from the wrong thread — which, in the worst-case scenario, can lead to a runtime crash. By default, everything runs on the main thread due to a lack of proper thread management, and when issues arise, we simply force execution on the main thread. Needless to say, this isn’t ideal and needs to be addressed.

Fortunately, a recent update to RealmSwift makes it compatible with Swift 6.

The first step is updating RealmSwift to a version compatible with Swift 6. There are two releases that add Swift 6 support: v10.54.0 and v20.0.0. One key difference is that version 20.0.0 removes Atlas Sync support, and it’s no longer necessary to explicitly specify the actor when opening a Realm instance.

The planLink icon

Database architecture|480

  1. Create a dedicated actor
  2. Make Realm asynchronous and thread-safe
  3. Generic abstraction layer to work with Realm objects
  4. First model and DAO using the abstraction layer
  5. Use the DAO

 Extra: Asynchronous observation of Realm data  

DatabaseActorLink icon

Let’s start by creating a dedicated actor that will allow us to safely read from and write to the database with regard to concurrency.

@globalActor public actor DatabaseActor: GlobalActor {
  public static let shared = DatabaseActor()
}

This actor will be very useful from this point on.

Async and thread-safe wrapper for RealmLink icon

Now we’ll create a wrapper around Realm using an actor (@DatabaseActor) and the Sendable protocol. This will allow us to expose asynchronous read/write operations while ensuring safe concurrent access.

import RealmSwift

public final class AsyncRealm: Sendable {
  private let configuration: Realm.Configuration
  
  public init(configuration: Realm.Configuration = .defaultConfiguration) {
    self.configuration = configuration
  }
  
  @DatabaseActor
  public func openRealm() async throws -> Realm {
    try await Realm.open(configuration: configuration)
  }
  
  @DatabaseActor
  public func write(_ block: @escaping (Realm) throws -> Void) async throws {
    let realm = try await openRealm()
    try await realm.asyncWrite {
      try block(realm)
    }
  }
  
  @DatabaseActor
  public func read<T: Object>(_ block: @escaping (Realm) throws -> T) async throws -> T {
    let realm = try await openRealm()
    return try block(realm)
  }
  
  @DatabaseActor
  public func readMany<T: Object>(_ block: (Realm) throws -> Results<T>) async throws -> Results<T> {
    let realm = try await openRealm()
    return try block(realm)
  }
}

Note that each read and write method is annotated with the @DatabaseActor attribute. This indicates that the method must be executed on that specific actor.

Another important detail is that the AsyncRealm class itself is not confined to the @DatabaseActor; only the methods are. This allows the class to be used and instantiated in any context without requiring execution on the actor. The configuration property, being private and immutable, can safely be accessed both during initialization and inside isolated methods. To ensure safe use across concurrent contexts, the class conforms to the Sendable protocol.

Abstraction LayerLink icon

The abstraction layer mainly exposes asynchronous and thread-safe operations to interact with Realm, relying on our dedicated AsyncRealm wrapper.

Thanks to this architecture, it’s possible to create a generic interface that acts as a bridge between Realm and our DAOs, allowing us to perform common operations (CRUD) safely and reusably, regardless of the model type.

And of course, all of this runs on our @DatabaseActor.

public final class DatabaseService: Sendable {
  private static let schemaVersion: UInt64 = 1
  private let database: AsyncRealm = AsyncRealm()
  public static let shared: DatabaseService = DatabaseService()
  
  private init() { }
}

@DatabaseActor
extension DatabaseService {
  public func save<T: Object>(_ object: T) async throws {
    try await database.write { realm in
      realm.add(object, update: .all)
    }
  }
  public func find<T: Object>(type: T.Type, id: String) async throws -> T {
    try await database.read { realm in
      guard let object = realm.object(ofType: type, forPrimaryKey: id)
      else { throw RealmError.unresolved }
      
      return object
    }
  }

// Other CRUD methods
}

Appointment Use CaseLink icon

Great, we now have all the pieces needed to perform various database operations. Let’s define our model and its DAO. We’ll use an example of an appointment object:

AppointmentModelLink icon

This is a typical Realm object with a primary key, a title, and a date.

import RealmSwift

public final class AppointmentModel: Object {
  @Persisted(primaryKey: true) public var id: String = UUID().uuidString
  @Persisted public var title: String?
  @Persisted public var date: Date?
}

AppointmentDAOLink icon

The DAO naturally relies on DatabaseService to handle appointment-related operations: creating, reading, updating, canceling, and listing.

All these operations are, again, confined to @DatabaseActor, ensuring thread-safe access.

import RealmSwift

public final class AppointmentDAO: Sendable {
  private let databaseService: DatabaseService
  
  public init(databaseService: DatabaseService) {
    self.databaseService = databaseService
  }
  
  @DatabaseActor
  public func getAllAppointments() async -> [AppointmentModel] {
    do {
      let results = try await databaseService.findAll(type: AppointmentModel.self)
      return Array(results)
    } catch {
      // Error handling
      return []
    }
  }
  
  @DatabaseActor
  public func getAppointment(id: String) async -> AppointmentModel? {
    do {
      return try await databaseService.find(type: AppointmentModel.self, id: id)
    } catch {
      // Error handling
      return nil
    }
  }
  
  @DatabaseActor
  public func saveAppointment(model: AppointmentModel) async {
    do {
      try await databaseService.save(model)
    } catch {
      // Error handling
    }
  }
  
  @DatabaseActor
  public func cancelAppointment(id: String) async {
    do {
      try await databaseService.delete(type: AppointmentModel.self, id: id)
    } catch {
      // Error handling
    }
  }
}

DAO usageLink icon

Now that we have our AppointmentDAO, how do we use it? Most use cases will require accessing the data outside our dedicated actor — for example, from a SwiftUI view, which runs on the MainActor.

So how do we bridge the two?

Actually, there’s not much to it: Swift takes care of actor isolation behind the scenes — just call the async method with await, and the system ensures safe execution on the correct actor.

struct AppointmentsView: View {

  private let dao = AppointmentDAO(
    databaseService: DatabaseService.shared
  )

  @State private var appointments: [AppointmentModel] = []

  var body: some View {
    List(appointments) { appointment in
      VStack {
        Text(appointment.title)
        Text(appointment.date)
      }
    }
    .onAppear {
      Task { @MainActor in
        self.appointments = await dao.getAllAppointments()
      }
    }
  }
}

Here, getAllAppointments() from the DAO will execute on DatabaseActor, even though it’s called from a Task running on the @MainActor.

The result is then safely assigned to appointments.

[!NOTE] Note

In the Task, the @MainActor annotation is explicitly specified as an example, but it’s not necessary here since the calling context is already on the @MainActor (via SwiftUI).

Still, it’s a good practice to make the intent clear, even if it’s not strictly required in this case.

Asynchronous Observation of Realm DataLink icon

Realm allows observing changes to objects, collections, or individual properties. When an observation is created, Realm returns a NotificationToken to manage its lifecycle. You can stop the observation at any time by invalidating this token.

With Swift 6 compatibility, Realm now supports asynchronous observation of objects or collections using async/await, and allows isolating those observations on a dedicated actor to ensure thread safety.

let token: NotificationToken = await appointments.observe(on: DatabaseActor.shared) { actor, changes in
  switch changes {
  case .initial(let collection):
  // Query has finished running and dogs can not be used without blocking
  case let .update(collection, deletions, insertions, modifications):
  // This case is hit:
  // - after the token is initialized
  // - when the name property of an object in the collection is modified
  // - when an element is inserted or removed from the collection.
  // This block is not triggered:
  // - when a value other than name is modified on one of the elements.
  case .error:
  // Can no longer happen but is left for backwards compatiblity
  }
}

AsynStreamLink icon

To perform async/await-based observation, we can use AsyncStream. AsyncStream is an asynchronous sequence that uses a continuation to yield new elements on demand.

Example

func streamOfValues() -> AsyncStream<Value> {
  return AsyncStream { continuation in
    // Something happen
    continuation.yield(newValue)
    // Everything is done
    continuation.finish()
  }
}

// Usage
for await value in streamOfValues() {
  // Update something with the updated value
}

(I love AsyncStream, it's so elegant and easy to use)

Let’s add an observation method to our DAO that combines AsyncStream with Realm’s observe to listen to data changes:

public final class AppointmentDAO: Sendable {
  @DatabaseActor
  private var notificationToken: NotificationToken?
  @DatabaseActor
  private var continuation: AsyncStream<[AppointmentModel]>.Continuation?

  deinit {
    continuation?.finish()
    notificationToken?.invalidate()
  }
  
  // Other DAO methods...
  
  public func observeAppointments() -> AsyncStream<[AppointmentModel]> {
    return AsyncStream { continuation in
      Task { @DatabaseActor in
        do {
          self.continuation = continuation
          let results = try await databaseService.findAll(type: AppointmentModel.self)
          
          // Start observation
          notificationToken = await results.observe(on: DatabaseActor.shared) { actor, changes in
            switch changes {
            case let .update(collection, deletions, insertions, modifications):
              continuation.yield(Array(collection))
            case .initial(let collection):
              continuation.yield(Array(collection))
            case .error(let error):
              // Handle error
              continuation.finish()
            }
          }
        } catch {
          // Handle error
        }
      }
    }
  }
}

Note that we retain instances of both the notificationToken and the continuation inside the class to properly stop the observation when the DAO instance is deinitialized. This prevents memory leaks and ensures the asynchronous stream completes cleanly.

Now we can use this elsewhere, like in a View, for example.

struct AppointmentsView: View {

  private let dao = AppointmentDAO(
    databaseService: DatabaseService.shared
  )
  
  @State private var appointments: [AppointmentModel] = []
  
  var body: some View {
    List(appointments) { appointment in
      VStack {
        Text(appointment.title)
        Text(appointment.date)
      }
    }
    .onAppear { 
      observeAppointments()
    }
  }
  
  private func observeAppointments() {
    Task { @MainActor in
      for await freshAppointments in dao.observeAppointments() {
        self.appointments = freshAppointments
      }
    }
  }
}

ConclusionLink icon

And there we go. With this setup, we’ve established a scalable and safe way to handle database access in Swift 6. This foundation makes it much easier to reason about concurrency, avoid threading issues, and observe data reactively using modern Swift paradigms.