If you want to update this page or add new content, please submit a pull request to the Homepage.
- Client
- Document
- Creating a Document
- Attaching the Document
- Initializing root
- Disabling GC
- Disabling Presence
- Updating presence
- Getting presence
- Editing the Document
- Subscribing to Document
- Recovering from refused writes
- Broadcasting a Message
- Changing Synchronization Mode
- Offline Persistence
- History (Undo/Redo)
- Detaching the Document
- Custom CRDT types
- Channel
- Logger Options
- Performance Best Practices
- Common Use Cases
- Troubleshooting
- Reference
iOS SDK
The Yorkie iOS SDK enables you to efficiently build collaborative applications. On the client side, you can create documents that automatically sync with remote peers with minimal effort.
If you want to install the SDK, refer to the Getting Started with iOS SDK.
Client
Client is a normal client that communicates with the server. It holds documents and sends local changes to the server to synchronize with other replicas.
For more information about Client, please refer to the Client.
Creating a Client
Create a Client using Client(). After activation, the Client connects to the server and is ready to use.
let client = Client("https://api.yorkie.dev", ClientOptions(apiKey: "xxxxxxxxxxxxxxxxxxxx"))try await client.activate()
The API key is used to identify the project in Yorkie. You can get the API key of the project you created in the Dashboard.
For CLI users, you can create Clients without an API key. In this case, the Client is created with a local rpcAddr.
let client = Client("http://localhost:{{port}}")try await client.activate()
For more information about CLI, please refer to the CLI.
Creating a Client with Auth Token
Configure authentication for the client by setting up an authTokenInjector to provide tokens for Auth Webhook verification.
If a codes.Unauthenticated error occurs, the authTokenInjector is called again with the webhook's response reason, enabling automatic token refresh.
For more information about Auth Webhook, please refer to the Auth Webhook.
struct MyAuthTokenInjector: AuthTokenInjector {func getToken(reason: String?) async throws -> String {if reason == "token expired" {return await refreshAccessToken()}return accessToken}}let client = Client("https://api.yorkie.dev", ClientOptions(apiKey: "xxxxxxxxxxxxxxxxxxxx", authTokenInjector: MyAuthTokenInjector()))
Client Identity
A Client carries two identifiers, and they are not interchangeable.
client.idis the session id, issued per activation. A new one is minted every time the client activates.client.getActorID()is the stable actor, supplied by the server on activation and derived from the project and the client key. It is what the SDK stamps into the document's changes.
let client = Client("https://api.yorkie.dev", ClientOptions(key: myPersistedClientKey,apiKey: "xxxxxxxxxxxxxxxxxxxx"))try await client.activate()let actor = client.getActorID() // nil before activation
The actor is stable only if the client key is. ClientOptions.key defaults to nil, and the SDK then generates a fresh UUID for every Client, so the actor changes on every launch. Keep your own key somewhere durable — the Keychain, or UserDefaults — and pass the same one each time. This matters most alongside Offline Persistence, where a document stored under one actor and resumed under another has its un-pushed changes dropped with reason actorMismatch.
Since v0.7.20 the actor — not the session id — is what keys presence and identifies peers in watched and unwatched events. An app that used client.id to find itself among peers must use client.getActorID(). Because the actor also breaks ties between concurrent edits and is derived from the client key rather than being time-ordered, the winner of a tie no longer follows client creation order.
Watch Stream Heartbeats
Since v0.7.25, the client asks the server for a heartbeat on an idle Watch stream, and reconnects a stream that stays silent for three of the intervals the server advertises, so a half-open connection no longer goes unnoticed. Set the interval with ClientOptions.watchHeartbeatInterval, in milliseconds like the other interval options. It defaults to 20000 (20 seconds); zero or a negative value opts out, leaving the stream without an idle watchdog. The server clamps the request into its own bounds (5 to 60 seconds by default) and echoes the effective value back when the stream starts.
let client = Client("https://api.yorkie.dev", ClientOptions(apiKey: "xxxxxxxxxxxxxxxxxxxx",watchHeartbeatInterval: 30000))
Syncing Documents Manually
In SyncMode.manual, the client does not automatically synchronize documents with the server. You must manually call the sync(document:) method to synchronize the document when necessary.
try await client.sync(doc)
Deactivate the Client
When the client is no longer needed, you can deactivate it to release resources and disconnect from the server.
try await client.deactivate()
This will detach all documents attached to the client for efficient garbage collection.
Document
Document is the primary data type in Yorkie, providing a JSON-like updating experience that makes it easy to represent your application's model.
A Document can be updated without being attached to the client, and its changes automatically propagate to other clients when the Document is attached to the Client or when the network is restored.
For more information about Document, please refer to the Document.
Creating a Document
Create a Document using Document(key:).
let doc = Document(key: "doc-1")
The document key identifies the Document in Yorkie. It is a string that you can freely define, but it can only contain a-z, A-Z, 0-9, -, ., _, ~ and must be between 4 and 120 characters.
The initializer also accepts a DocumentOptions value for document-scope settings:
let doc = Document(key: "doc-1", opts: DocumentOptions(disableGC: false, disablePresence: true))
disableGC: Disables local garbage collection for this document. This argument is required (it has no default). See Disabling GC.enableDevtools(Optional): Enables the Devtools recorder for this document. Defaults tofalse.disablePresence(Optional): Declares that this document does not use presence. Defaults tofalse. See Disabling Presence.maxUndoDepth(Optional): How many changesundo()can step back through, and likewiseredo()forward. Defaults to50. A value below1falls back to the default, with a warning logged. See History (Undo/Redo).
The disablePresence value seeds the attach-time option of the same name: when the disablePresence attach option is omitted, the document's value is used. DocumentOptions.disableGC gates local garbage collection and is set independently of the attach-time disableGC option, which controls the server wire contract.
Attaching the Document
When you attach, the client notifies the server that it is subscribing to this document. If the document does not exist on the server, it is created, and any local changes are sent to the server. If the server already has a document associated with the provided key, it sends the existing changes to the client, which applies them to synchronize the document.
Once attached, the document synchronizes with other clients, ensuring that any modifications made by one client instantly propagate to all other clients collaborating on the same document.
The attach method accepts several options:
initialPresence: Sets the client's initial presence when attaching to the document. The presence is shared with other users participating in the document. It must be serializable to JSON.syncMode(Optional): Specifies the synchronization mode. The default value isSyncMode.realtime, which automatically pushes and pulls changes. If you set it toSyncMode.manual, you must handle synchronization manually. See Changing Synchronization Mode for the full list, includingSyncMode.pollingfor stream-less periodic sync.documentPollInterval(Optional): Poll interval as aTimeIntervalin seconds. Used only whensyncModeisSyncMode.polling. Defaults to3.0. (Note: unlike the JS SDK's millisecond value, iOS uses seconds.)disableGC(Optional): Opts the attachment out of server-sideVersionVectortracking, reducing per-PushPullpayload for high-fan-out workloads. Defaults tofalse. See Disabling GC before enabling.disablePresence(Optional): Opts the attachment out of presence map propagation for documents that don't need presence (e.g.JSONCounter-only, form, or read-mostly documents). Defaults to the document's seeded value (falseunless set viaDocumentOptions). See Disabling Presence before enabling.
try await clientA.attach(doc, ["color": "blue", "cursor": ["x": 0, "y": 0]], .manual)
Since v0.7.25, when the client key is generated (no ClientOptions.key), edits made to a Document before attach are re-issued to the client's actor on attach. Positions, element ids or other identifiers taken from the Document before attaching no longer resolve afterwards; read them again once attach returns. With an explicit ClientOptions.key, pre-attach edits keep their original actor.
Initializing root
The root manages the application's data, such as primitives, arrays, Counters, and Text within the Document. You can set initial values when calling Document.attach() using the initialRoot option.
try await client.attach(doc, initialRoot: ["list": [Int32(1), Int32(2), Int32(3)]])
The initial values are partially applied. For each element in initialRoot:
- If the key doesn't exist, the element is applied.
- If the key already exists in the Document, that element is discarded. You don't need to worry about overwriting existing values.
// Another client tries to attach with initialRoot option:try await client2.attach(doc, initialRoot: ["list": [], // this update will be discarded"counter": JSONCounter(value: Int32(0)) // this update will be applied])// final state// root = {// list: [1, 2, 3],// counter: 0// }
We support element types for Primitives, and Custom CRDT types.
Elements added by initialRoot are not sent to the server during the attach process. They are applied locally to the Document after push-pull during attach.
Disabling GC
For documents whose workload never produces tombstones — typically JSONCounter-only or primitive-value replacement documents — you can attach with disableGC: true. The server then skips minVersionVector tracking for this attachment and omits the response VersionVector, which removes a per-PushPull cost that scales with the number of active clients.
try await client.attach(doc, disableGC: true)
disableGC only controls the wire contract with the server. Misuse on documents that use JSONText, JSONTree, or JSONArray deletions leads to undefined garbage-collection behavior on this client — tombstones may accumulate without being purged. Only enable it for workloads you are certain never produce tombstones.
Disabling Presence
For documents that don't need presence — typically JSONCounter-only, form, or read-mostly documents — you can attach with disablePresence: true. The server then strips the presence map from snapshots and changes, and no presence updates are propagated for the attachment. Any presence.set calls inside doc.update are silently dropped.
try await client.attach(doc, disablePresence: true)
disablePresence is fixated on the first attach and is immutable afterwards. If a later attach specifies a different value, the server returns the persisted value rather than the requested one. To change it, create a new document key, or have an administrator update the value directly in storage.
Updating presence
The Document.update() method allows you to make changes to the current user's presence.
Specified properties are changed while the existing presence object is updated by merging the new changes. Properties not specified in the update function remain unchanged.
try await doc.update { root, presence inpresence.set(["cursor": ["x": 1, "y": 1]])}// final state// presence = { color: 'blue', cursor: { x: 1, y: 1 } }// we can see that the changes made were merged and the final state of the current user's presence is as we desire
Since v0.7.25, when the server advertises support for it, the SDK sends a presence update as a patch against the presence the server already holds for this client, rather than the whole presence. This is transparent to the app: presence.set and the presence events behave as before.
Important: Properties in presence are replaced entirely, not merged.
To preserve nested properties, include all fields in your update:
// ❌ This loses the 'color' propertypresence.set(["cursor": ["x": 1, "y": 1]])// ✅ This preserves all propertiespresence.set(["cursor": ["x": 1, "y": 1, "color": "red"]])
Getting presence
Retrieve presence information for clients participating in the document.
Getting specific client presence:
await doc.getPresence(client.getActorID()!)
Presences are keyed by the stable actor, not by client.id. See Client Identity.
Getting your own presence:
await doc.getMyPresence()
Getting all presences:
let users = await doc.getPresences()
Document.subscribePresence(.presence)
This method subscribes to presence-related changes. You are notified whenever clients watch, unwatch, or modify their presence.
The initialized event occurs when the client list needs to be initialized, such as when you first connect a watch stream to a document, when the connection is lost, or when it is reconnected.
Subscribe before attaching the document to ensure you receive the initial initialized event.
await doc.subscribePresence { event inif event.type == .initialized {// Array of other users currently participating in the document// event.value;}if event.type == .watched {// A user has joined the document editing in online// event.value;}if event.type == .unwatched {// A user has left the document editing// event.value;}if event.type == .presenceChanged {// A user has updated their presence// event.value;}}
Use .myPresence and .others topics to distinguish between your own events and those of others.
Document.subscribePresence(.myPresence)
This method subscribes to changes in the presence of the current client that has attached to the document.
The possible event.type are: initialized, presenceChanged.
await doc.subscribePresence(.myPresence) { event in// Do something}
Document.subscribePresence(.others)
This method subscribes to changes in the presence of other clients participating in the document.
The possible event.type are: watched, unwatched, presenceChanged.
await doc.subscribePresence(.others) { event inif event.type == .watched {addUser(event.value)}if event.type == .unwatched {removeUser(event.value)}if event.type == .presenceChanged {updateUser(event.value)}}
Editing the Document
Document.update(:,message:) modifies a Document. The optional message parameter allows you to add a description to the change. If the Document is attached to the Client, all changes automatically synchronize with other Clients.
let message = "update document for test";try await doc.update({ root, _ inroot.todos = Array<String>()(root.todos as? JSONArray)?.append("todo-1")root.obj = ["name": "yorkie", "age": Int64(14)]root.counter = JSONCounter(Int64(0))(root.counter as? JSONCounter<Int64>)?.increase(1)}, message: message);
Under the hood, root in the update function creates a change, a set of operations. Every element has a unique ID created by the logical clock. Yorkie uses this ID to track each object.
update throws when the change cannot be applied to the Document — for example, a JSONTree edit at an index that splits a UTF-16 surrogate pair throws a YorkieError with code .errInvalidArgument. Since v0.7.24 the error reaches the caller instead of being swallowed, and the Document is left as it was before the callback ran.
You can get the contents of the Document using doc.getRoot().
let root = doc.getRoot()print(root.todos!) // Optional(["todo-1"])print(root.obj!) // {"name":"yorkie","age":14}print((root.obj as! JSONObject).name!) // yorkieprint(root.counter!) // 1
Subscribing to Document
Subscribe to various Document events, such as changes, connection status, and synchronization status using the document.subscribe() method.
By subscribing to these events, you can update the UI in real-time and handle exceptions that may occur during synchronization.
Document.subscribe()
A Document can be modified by changes generated remotely or locally. Whenever the Document is modified, change events are triggered. You can subscribe to these events using the document.subscribe(callback) method to receive updates in real-time, which is useful for updating the UI when the Document changes.
The callback is called with an event object, and the event.type property indicates the source of the change, which can be one of the following values: statusChanged, connectionChanged, syncStatusChanged, snapshot, localChange, remoteChange, initialized, watched, unwatched, presenceChanged, broadcast, localBroadcast, authError, epochMismatch, writeRejected or localChangesDropped.
For more information about document events, please refer to the DocEvent.
await self.document.subscribe { event, _ inif event.type == .snapshot {// `snapshot` delivered when the entire document is updated from the server.} else if event.type == .localChange {// `local-change` delivered when calling document.update from the current client.} else if let event = event as? RemoteChangeEvent {// `remote-change` delivered when the document is updated from other clients.let changeInfo = event.value// You can access the operations that have been applied to the document.changeInfo.operations.forEach { op in// e.g.) { type: 'increase', value: 1, path: '$.counter' }switch (op.type) {case .increase:// ...breakdefault:break}}} else {// others case...}}
When the event.type is localChange or remoteChange, the event.value is a changeInfo, which has operations and message properties.
Since v0.7.24, a local change, undo() or redo() publishes a localChange event whenever it ran an operation, even when operations is empty — for example, a style over a range with no text, or removing an attribute that is not set.
For more information about changeInfo for document events, please refer to the ChangeInfo.
The snapshot event is triggered when a snapshot is received from the server. This occurs when the number of changes that a document needs to fetch from the server exceeds a certain SnapshotThreshold. Instead of sending numerous changes, the server sends a snapshot of the document. In such cases, update your application with data from the Yorkie Document.
The available Event Types are:
| Event Types | Description |
|---|---|
Snapshot | Document snapshot received from server |
LocalChange | Document changed by current client |
RemoteChange | Document changed by another client |
PresenceChanged | Presence data changed (see Presence section) |
SyncStatusChanged | Sync status changed (Synced, SyncFailed) |
StreamConnectionChanged | Connection status changed (Connected, Disconnected) |
DocumentStatusChanged | Document status changed |
Broadcast | Broadcast message received |
AuthError | Authentication error occurred |
WriteRejected | Server refused to store the pushed changes (see Document.subscribeWriteRejected()) |
If a client has not synchronized for a prolonged period and then makes a sync request, it might receive a snapshot event.
Ensure your application processes these snapshot events correctly to maintain document synchronization.
When a change from another client cannot be applied, sync fails with a YorkieError whose code is .errChangeApplyFailed. Its message names the document, the change and the operation that failed, and includes the original error. The change is not skipped: the server sends it again on the next sync, so the error repeats until the cause is fixed.
writeRejected was added to DocEventType, and writeDenied to LocalChangesDroppedValue.Reason, in v0.7.25. An exhaustive switch over either enum will not compile until it handles the new case; add a default branch so later additions do not break the build again.
Document.subscribe("$.path")
You can also subscribe to changes for a specific path in the Document using doc.subscribe(targetPath, callback) with a path argument, such as $.todos, where the $ sign indicates the document root.
The callback function is called when the target path and its nested values change.
This feature lets you subscribe to changes for a specific part of the document and perform different actions based on the updated values.
await doc.subscribe("$.todos") { event in// The callback will be called when the root.todos or its nested values change.Task {let target = try? await target.getValueByPath(path: "$.todos") // You can get the value by path.}// Do something...}
Document.subscribeConnection()
After attaching the document to the client, the document continuously synchronizes with the server in real-time. This is achieved by maintaining a watch stream connection between the client and the server, which allows the client to receive events and updates from other users.
To monitor the connection status of the stream, use a callback function that triggers whenever the connection status changes. The possible values for event.value are StreamConnectionStatus.connected and StreamConnectionStatus.disconnected.
When the watch stream is disconnected, the user is offline and will not receive real-time updates from other users.
await self.document.subscribeConnection { event, _ inlet event = event as! ConnectionChangedEventif event.value == .connected {// The watch stream is connected.} else if event.value == .disconnected {// The watch stream is disconnected.}}
For more information about StreamConnectionStatus, please refer to the StreamConnectionStatus.
Document.subscribeSync()
If the document is attached to the client in SyncMode.realtime, the document automatically synchronizes with the server in real-time.
In this mode, the document synchronizes in the background, and you can track the synchronization status using the sync event. The possible event.value values are DocumentSyncStatus.synced and DocumentSyncStatus.syncFailed.
await self.document.subscribeSync { event, _ inlet event = event as! SyncStatusChangedEventif event.value == .synced {// The document is synchronized with the server.} else if event.value == .syncFailed {// The document failed to synchronize with the server.}}
For more information about DocumentSyncStatus, please refer to the DocumentSyncStatus.
Document.subscribeAuthError()
Subscribe to authentication error events using doc.subscribeAuthError().
This event triggers when an unauthenticated error occurs during PushPull or WatchDocuments operations, specifically when the system automatically refreshes the token and retries the operation.
await doc.subscribeAuthError { event, _ inguard let authErrorEvent = event as? AuthErrorEvent else { return }// authErrorEvent.value contains:// - reason: string// - method: 'PushPull' | 'WatchDocuments'}
This subscription allows you to monitor when token refreshes occur due to authentication errors. For more information about Auth Webhook, see the Auth Webhook documentation.
Since v0.7.25, an ErrPermissionDenied on PushPull — for example, an auth webhook that starts refusing this client's writes — also publishes an AuthErrorEvent with method .pushPull, so the app can tell a refused write from a network failure. A denial on the Watch stream does not. Because an ErrUnauthenticated on PushPull publishes the same event while the token is refreshed and the call retried, and the event carries no error code, tell the two apart by the reason your auth webhook returns before recovering. See Recovering from refused writes.
Document.subscribeWriteRejected()
Since v0.7.25, a push the server refuses with ErrDocumentSizeExceedsLimit or ErrChangeTooLarge is no longer retried, because resending the same changes cannot change the verdict. The document is parked: the sync loop skips it while the client's other documents keep syncing, and its rejected changes stay applied locally but do not reach the server. Subscribe with doc.subscribeWriteRejected() to tell the user, for example by switching the editor to read-only.
await doc.subscribeWriteRejected { event, _ inguard let event = event as? WriteRejectedEvent else { return }// event.value.code: .documentSizeExceedsLimit or .changeTooLarge// event.value.reason: the server's message// event.value.method: "PushPull"}
The document leaves the parked state when an explicit client.sync(doc) is accepted by the server, for example after the document has been shrunk, or when it is recovered with client.resync.
Recovering from refused writes
When the server keeps refusing a document's writes — an ErrPermissionDenied from an auth webhook, or a rejected write — the refused changes stay in the queue and every later sync sends them again. client.resync(_:discardLocalChanges:initialPresence:) discards them and re-anchors the same Document instance on the server's state, so the app's subscriptions and any editor bound to the document survive. It re-attaches with the sync mode, disableGC option and poll interval the document had, and with its current presence unless you pass initialPresence.
// Once the app has decided the server is refusing this document's writes:let dropped = try await client.resync(doc, discardLocalChanges: true)for change in dropped {print("discarded \(change.operationCount) ops (clientSeq \(change.clientSeq))")}
discardLocalChanges must be true; it keeps the data loss visible at the call site. The call returns the discarded changes, and they are also published as a LocalChangesDroppedEvent with reason writeDenied once the detach has succeeded. While the document is being re-anchored, doc.isMintable is false and update, undo() and redo() throw a YorkieError with code .errRefused; an edit made before the discard is part of the discarded set.
If the server refuses the detach, resync throws and the document is left attached with its queue intact, so the call can be retried. Offline persistence, if set, is torn down for that attachment and stays off until the next attach, as in the JS SDK: the document keeps working in memory, but its un-pushed changes are not saved in the meantime.
Broadcasting a Message
Broadcasting allows clients to share custom events with other clients connected to the same document. Unlike document updates or presence changes, broadcasts are ephemeral messages that are not persisted in the document state.
// Step 1: Define your message structurelet payload = Payload(["message": "Hello!"])// Step 2: Broadcast to topictry await doc.broadcast(topic: "chat", payload: payload)// Step 3: Subscribe and handletry await doc.subscribeBroadcast { event, _ inguard let event = event as? BroadcastEvent else { return }print("Received: \(event.value.payload["message"] ?? "")")}
The broadcast event contains:
topic: A string identifying the type of broadcast messagepayload: The data being broadcast (must be JSON serializable)clientID: The ID of the client that sent the broadcast
This feature is useful for sending temporary messages or signals between clients, such as:
- Chat messages
- Cursor movement notifications
- Application-specific events
- User action notifications
Broadcast messages are not persisted and are only delivered to clients currently connected to the document. Clients that connect later will not receive previous broadcast messages.
Changing Synchronization Mode
To change the synchronization mode for a document, use client.changeSyncMode(doc, syncMode).
Yorkie offers five SyncModes:
-
SyncMode.realtime: Local changes are automatically pushed to the server, and remote changes are pulled from the server. -
SyncMode.realtimePushOnly: Only local changes are pushed, and remote changes are not pulled. -
SyncMode.realtimeSyncOff: Changes are not synchronized, but the watch stream remains active. -
SyncMode.manual: Synchronization no longer occurs in real-time, and the watch stream is disconnected. Manual handling is required for synchronization. -
SyncMode.polling: The sync loop runs without opening a watch stream.PushPullChangesruns at the polling interval, so remote changes arrive on the next tick (latency ≈ the interval). Recommended for large, low-interactivity documents where broadcast events are not needed; not suitable for collaborative editing — useSyncMode.realtimefor that. Set the interval with thedocumentPollIntervalattach option (TimeIntervalin seconds, default3.0).
// Enable automatic synchronization of both local and remote changes.try await client.changeSyncMode(doc, realtime)// Only push local changes automatically.try await client.changeSyncMode(doc, .realtimePushOnly)// Synchronization turned off, but the watch stream remains active.try await client.changeSyncMode(doc, .realtimeSyncOff)// Stream-less periodic sync at the document's poll interval.try await client.changeSyncMode(doc, .polling)// Synchronization turned off, and the watch stream is disconnected.try await client.changeSyncMode(doc, manual)try await client.sync(doc) // Trigger synchronization manually using the sync function.
To start a document in polling mode from the beginning, pass it at attach time along with an optional interval:
try await client.attach(doc, [:], .polling, documentPollInterval: 5.0)
Offline Persistence
By default a Document lives only in memory: if the app is killed with changes that have not reached the server, those changes are gone. ClientOptions.store gives the client somewhere to persist every document attached to it, so un-pushed edits can be replayed and pushed on the next successful sync.
let client = Client("https://api.yorkie.dev", ClientOptions(key: myPersistedClientKey,apiKey: "xxxxxxxxxxxxxxxxxxxx",store: MyDurableDocStore()))
Surviving an app restart takes a durable store and a stable client key. The built-in MemoryDocStore keeps documents in a process-local dictionary, so it recovers nothing once the process is gone — it exists so the resume path is usable without the SDK committing to a storage location, which makes it useful for trying the API out or for resuming within a single run, and nothing more. Implement DocStore over your own storage to survive restarts, and pass a ClientOptions.key you persist, or the restored document is resumed under a new actor and dropped with reason actorMismatch. See Client Identity.
Implementing a DocStore
A store holds, per document key, a base snapshot plus an append-only log of the changes made since it. That shape is what keeps writes cheap: appending costs the size of one change, which does not grow with the document, whereas re-serializing the whole document costs time proportional to its size — and a document is at its largest exactly while it is being edited.
public protocol DocStore: Sendable {func load(docKey: String) async throws -> StoredDoc?func saveSnapshot(docKey: String, bytes: Data) async throwsfunc appendChange(docKey: String, change: StoredChange) async throwsfunc saveMeta(docKey: String, bytes: Data) async throwsfunc remove(docKey: String) async throws}
| Method | Contract |
|---|---|
load | Returns the stored state, whose changes must be ordered by ascending clientSeq, or nil when the key is absent. |
saveSnapshot | Replaces the snapshot and atomically drops every appended change and any stored meta. This is compaction — the new snapshot already contains those changes, and it embeds a newer header than meta holds. |
appendChange | Appends one local change. This is the hot path, so it must not rewrite the whole entry. It is an upsert keyed by clientSeq: re-appending a stored change replaces it, so a retried write is safe. |
saveMeta | Records the post-sync header. Leaves the snapshot alone, and leaves the log alone — only compaction trims the log. |
remove | Removes everything persisted for the key. Removing an absent key succeeds. |
The bytes are opaque to the SDK, so an implementation is free to compress or encrypt what it is handed.
appendChange and saveMeta must be a no-op when nothing is stored for the key — they must never create an entry. An entry holding a log with no snapshot under it cannot be restored, and the client discards an unrestorable entry, taking the un-pushed changes in that log with it. The client always writes a base snapshot before it appends.
DocStore changed shape in v0.7.22. It was previously one opaque blob: load returned Data? and save replaced it. Apps using MemoryDocStore are unaffected, but an app with its own store must migrate to StoredDoc, saveSnapshot, appendChange and saveMeta.
The key passed to DocStore is built from the API key, the client key and the document key. Since v0.7.24, a / or % in any of them is escaped (%2F, %25), so two different identities can never share an entry or a session lock. A document stored by an earlier version under a key containing either character is not found after upgrading; push its changes before upgrading. The client also logs a warning when store is set without a ClientOptions.key, because the stored document cannot be resumed under a new actor.
Sharing a store across processes
Two sessions resuming the same persisted document would both replay its log and both push, duplicating the work. ClientOptions.sessionLock guards against that, and it is consulted only when store is set. The default is NoopSessionLock, which grants every lease — correct for a single process. Supply your own when a durable store is shared across processes, such as an app and its extensions.
let client = Client("https://api.yorkie.dev", ClientOptions(apiKey: "xxxxxxxxxxxxxxxxxxxx",store: MyDurableDocStore(),sessionLock: MySessionLock()))
When local changes are dropped
Some resumes cannot be reconciled with the server at all. When that happens the SDK keeps what it can and publishes a LocalChangesDroppedEvent describing what was lost, so the app can surface the data loss or re-apply the changes on top of the re-anchored state.
await doc.subscribe { event, _ inguard let event = event as? LocalChangesDroppedEvent else { return }for change in event.value.changes {// change.actorID, change.clientSeq, change.lamport,// change.message, change.operationCount, change.hasPresenceChangeprint("dropped \(change.operationCount) ops from \(change.actorID ?? "unknown")")}}
event.value.reason says why:
| Reason | Meaning |
|---|---|
epochReanchor | The document was compacted server-side, so the resume re-anchored from a snapshot. |
documentPurged | The server no longer has the document the changes were made against. |
actorMismatch | The stored document belongs to a different actor than the one resuming it. |
restoreFailed | The stored bytes could not be decoded at all, so nothing was restored. |
writeDenied | The server refused this client's writes, and client.resync discarded them to re-anchor the document. See Recovering from refused writes. |
logDiscontinuity | The appended change log had a hole, so it could not be replayed. The snapshot restored normally; what was lost is the log written after it. |
logDiscontinuity was added to LocalChangesDroppedValue.Reason in v0.7.22. An exhaustive switch over Reason will not compile until it handles the new case.
Unlike the JS SDK, the iOS SDK does not yet cap how much work a persist may do — upstream's maxPersistBytes and maxPersistMillis budgets are not ported. A pulling sync on a very large document re-serializes it with no ceiling, which is a stall proportional to document size rather than a correctness problem.
History (Undo/Redo)
A Document keeps an undo stack and a redo stack of the local client's own changes. Unlike the JS SDK, which exposes them through doc.history, the iOS SDK puts them directly on Document.
try await doc.update { root, _ inroot.text = JSONText()(root.text as? JSONText)?.edit(0, 0, "Hello")}try await doc.undo() // {"text":[]}try await doc.redo() // {"text":[{"val":"Hello"}]}
canUndo and canRedo report whether the matching stack still has something to replay. Both are false while a doc.update callback is running.
try await doc.update { root, _ inroot.todos = Array<String>()(root.todos as? JSONArray)?.append("todo-1")}await doc.canUndo // trueawait doc.canRedo // falsetry await doc.undo()await doc.canUndo // falseawait doc.canRedo // true
Calling undo() or redo() on an empty stack is a no-op rather than an error, so checking canUndo / canRedo first is optional. Applying a new local change clears the redo stack.
Supported operations
Undo and redo replay reverse operations, which are produced for:
JSONObject: setting and removing propertiesJSONArray: append, insert, remove, move, and whole-array replacementJSONText:edit,setStyle, andremoveStyleJSONTree:edit,style, andremoveStyle
JSONCounter.increase produces no reverse operation, so it contributes nothing to the undo stack: a change made up only of increase calls is skipped by undo(), and in a mixed change the other operations are reverted while the counter keeps its value. Presence changes are likewise not restored by undo.
JSONTree undo/redo requires a Yorkie server v0.7.16 or later, because the node identity rules it relies on are part of the wire contract shared with the server. See Getting Started with iOS SDK.
Clearing the history
clearHistory() empties both stacks. The SDK calls it for you after attach, so elements created through initialRoot are never reachable via undo, and again whenever a server snapshot replaces the local state. Call it yourself to drop one-time setup changes you made after attaching.
await doc.clearHistory()
Limitations
- Maximum stack depth: 50 changes per stack by default; set
DocumentOptions.maxUndoDepthto change it. Pushing beyond that drops the oldest entry. - Not available during update: calling
undo()orredo()inside adoc.updatecallback throws aYorkieErrorwith code.errRefused("Undo is not allowed during an update", or "Redo is not allowed during an update" fromredo()). - Local only: only the local client's own changes enter the stacks. Remote changes are applied to the Document but are never reverted by your
undo().
Undo and redo affect only your own editing history, and the result still converges with concurrent edits from other clients.
Detaching the Document
If the document is no longer needed, detach it to improve the efficiency of garbage collection removing CRDT tombstones. For more information about GC, see Garbage Collection.
Since v0.7.24, a tombstone is collected only once the element after it is known to every client, so a change still on its way to another client never anchors on a purged node. getGarbageLength() can therefore stay higher for a while after a sync, until the other clients catch up or detach.
try await client.detach(doc)
Custom CRDT types
Custom CRDT types are specialized data types for applications such as text editors and counters, unlike general JSON data types such as JSONObject and JSONArray. Create custom CRDT types in the callback function of document.update.
JSONText
JSONText provides support for collaborative text editing. Contents in JSONText can have attributes; for example, characters can be bold, italic, or underlined.
try await doc.update{ root inroot.text = JSONText() // {"text":[]}(root.text as? JSONText)?.edit(0, 0, "hello") // {"text":[{"val":"hello"}]}(root.text as? JSONText)?.edit(0, 1, "H") // {"text":[{"val":"H"},{"val":"ello"}]}(root.text as? JSONText)?.setStyle(0, 1, ["bold": true]) // {"text":[{"attrs":{"bold":true},"val":"H"},{"val":"ello"}]}(root.text as? JSONText)?.setStyle(1, 3, ["color": "red"]) // {"text":[{"attrs":{"bold":true},"val":"H"},{"attrs":{"color":"red"},"val":"el"},{"val":"lo"}]}}
Indexes count UTF-16 code units, the same as the JS SDK, so an emoji such as 👍 takes two. Since v0.7.24, an index that falls between the two halves of such a pair is rejected: edit returns nil and setStyle returns false, and the error is logged. JSONTree methods throw a YorkieError with code .errInvalidArgument instead.
Attribute values
An attribute value is stored as text. Booleans, numbers, arrays, and objects are stored as JSON (true, 12). Since v0.7.23, a string is stored as itself (red), the same way the Yorkie server and other SDKs store it, unless the string would itself parse as JSON — "1", "true", or "null" keep their quotes so they read back as strings rather than as a number, a boolean, or null. This raw form is what the attributes in StyleOpInfo and EditOpInfo carry, so an app that matched a string attribute as "\"red\"" before v0.7.23 now receives red.
An attribute counts toward the document size limit by the UTF-8 length of its key and value, matching the server.
A style concurrent with a deletion is applied on every replica, including to text another client has already deleted. Undoing that style and then the deletion brings the text back without the attributes it carried. All clients and the server must be on v0.7.23 or later to agree on this.
Selection using presence
Temporary client information, such as text selection, does not need to be stored in the document permanently. Instead, share it using presence.
When transmitting text selection information, convert the index, which can vary based on the text state, into the position used by Yorkie.JSONText. The converted position selection can then be sent and applied through presence.
Here is an example where presence is used to share text selection between users in UITextView.
- When the text selection is changed:
let range: NSRange // eg. selected range converted from selectedTextRange of UITextViewlet fromIdx = range.locationlet toIdx = range.location + range.lengthtry await doc.update { root, presence inif let range = try? (root.content as? JSONText)?.indexRangeToPosRange((fromIdx, toIdx)) {presence.set(["from": range.0, "to": range.1])}}
- When applying other user's selection changes:
await doc.subscribePresence(.others) { event inif let event = event as? PresenceChangedEvent {if let fromPos: TextPosStruct = decodePresence(event.value["from"]),let toPos: TextPosStruct = decodePresence(event.value["to"]) {if let (fromIdx, toIdx) = try? await(document.getRoot().content as? JSONText)?.posRangeToIndexRange((fromPos, toPos)) {// Handle the updated selection in the editor}}}}private func decodePresence<T: Decodable>(_ dictionary: Any?) -> T? {guard let dictionary = dictionary as? [String: Any],let data = try? JSONSerialization.data(withJSONObject: dictionary, options: [])else {return nil}return try? JSONDecoder().decode(T.self, from: data)}
Text selection can be efficiently shared using presence. Refer to the following example for complete code:
An example of Text Editor: Text Editor example
JSONCounter
JSONCounter supports integer types with addition and subtraction operations. If integer data needs to be modified simultaneously by multiple clients, use JSONCounter instead of primitives.
try await doc.update{ root inroot.counter = JSONCounter(value: Int64(1)) // {"counter":1}(root.counter as? JSONCounter<Int64>)?.increase(value: 2) // {"counter":3}(root.counter as? JSONCounter<Int64>)?.increase(value: 3) // {"counter":6}(root.counter as? JSONCounter<Int64>)?.increase(value: -4) // {"counter":2}}
Channel
Channel is a communication channel that enables real-time pub/sub messaging and presence tracking between clients. Unlike Documents, which persist and synchronize data, Channels are designed for ephemeral, real-time communication such as chat messages, notifications, and presence indicators.
For more information about Channel, please refer to the Channel.
Creating a Channel
Create a Channel with a unique key and attach it to the Client. The Channel initializer is throwing, because it validates the key:
let channel = try Channel(key: "room-123")try await client.attachChannel(channel)
The channel key identifies the Channel in Yorkie. Clients with the same channel key can communicate with each other through pub/sub messaging.
By default a channel syncs in realtime, opening a watch stream so broadcast events are delivered as they happen. For large channels where you only need an approximate session count and don't need broadcast events, create the channel with isRealtime: false — no watch stream is opened, and you refresh the count on your own cadence with syncChannel:
// Realtime (default): opens a watch stream for broadcast events.let roomChannel = try Channel(key: "room-123")try await client.attachChannel(roomChannel)// Non-realtime: stream-less, for large viewer counts where broadcast is not needed.let viewerChannel = try Channel(key: "viewer-room", isRealtime: false)try await client.attachChannel(viewerChannel)
Unlike the JS SDK, iOS sets a channel's sync behavior at construction time via the isRealtime flag rather than an attach option. There is no per-attach channelHeartbeatInterval; the client uses a fixed 5-second heartbeat interval internally.
Hierarchical Channel Keys
Channels support a hierarchical structure using periods (.) as separators, allowing you to organize channels into logical groups and subgroups. This structure is particularly useful for managing complex applications with multiple rooms, sections, or nested contexts.
let roomChannel = try Channel(key: "room-1")let sectionChannel = try Channel(key: "room-1.section-a")let userChannel = try Channel(key: "room-1.section-a.user-123")try await client.attachChannel(roomChannel)try await client.attachChannel(sectionChannel)try await client.attachChannel(userChannel)
Each level in the hierarchy maintains its own presence count and can be used independently:
room-1might track all users in a game roomroom-1.section-amight track users in a specific section of that roomroom-1.section-a.user-123might track presence for a specific user's context
Channel Key Restrictions:
The key is validated when the Channel is created (a YorkieError is thrown otherwise):
| Rule | Invalid Examples | Valid Examples |
|---|---|---|
| Cannot be empty or contain whitespace | ❌ ""❌ room 1 | ✅ room-1 |
| Cannot start with a period | ❌ .room-1❌ .room-1.section-1 | ✅ room-1✅ room-1.section-1 |
| Cannot end with a period | ❌ room-1.❌ room-1.section-1. | ✅ room-1✅ room-1.section-1 |
| Cannot contain consecutive periods | ❌ room-1..section-1❌ room..section..subsection | ✅ room-1.section-1✅ room.section.subsection |
Important: Distribute First-Level Channel Keys
Yorkie servers determine which server handles a channel based on a combination of your project and the first-level key of the channel. Using the same first-level key for all channels means that a single server manages all your channels, which can lead to performance bottlenecks and uneven load distribution.
Design your channel key structure to naturally distribute traffic across different first-level prefixes for optimal performance.
// ✅ Good: Distributed first-level keystry Channel(key: "game-1.room-a")try Channel(key: "chat-1.thread-1")try Channel(key: "chat-2.thread-1")try Channel(key: "notification-1.user-123")// ❌ Bad: Same first-level key// All channels share 'app' as first level - avoid this!try Channel(key: "app.game-1")try Channel(key: "app.game-2")try Channel(key: "app.chat-1")try Channel(key: "app.notification-1")
Broadcast
Channels support a pub/sub pattern for broadcasting ephemeral messages to all connected clients. Subscribe to a topic with subscribeTopic, and send messages with broadcast.
// Subscribe to messages on a specific topicchannel.subscribeTopic("chat") { event inprint("[\(event.topic)] \(event.payload["message"] ?? "")")print("From: \(event.clientID)")}// Broadcast a message to all clients in the same channeltry channel.broadcast(topic: "chat", payload: Payload(["message": "Hello, world!"]))
The ChannelBroadcastEvent object contains:
topic: The topic name you subscribed topayload: The message data as aPayload(must be JSON serializable)clientID: The ID of the client that sent the broadcast
You can subscribe to multiple topics on the same channel, and subscribeBroadcast receives every remote broadcast regardless of topic:
channel.subscribeTopic("chat") { event inprint("Chat message: \(event.payload)")}channel.subscribeTopic("notification") { event inprint("Notification: \(event.payload)")}// Or handle all broadcast topics in one placechannel.subscribeBroadcast { event inprint("[\(event.topic)] \(event.payload)")}try channel.broadcast(topic: "chat", payload: Payload(["message": "Hello!"]))try channel.broadcast(topic: "notification", payload: Payload(["type": "user-joined", "user": "Alice"]))
You can also broadcast without a Channel reference by using the channel key directly on the client:
try await client.broadcast("room-123", topic: "chat", payload: Payload(["message": "Hello!"]), options: nil)
Broadcast messages are ephemeral and only delivered to clients currently connected to the channel. Messages are not persisted or stored, so clients that join later will not receive previous messages.
Tracking Online Sessions
Channels automatically track the number of connected clients, making it easy to display session counters. Subscribe to presence changes with subscribePresenceChange, and read the current value with getSessionCount().
// Subscribe to presence (session count) changeschannel.subscribePresenceChange { event inprint("Sessions: \(event.count)")}// Get the current session countlet count = channel.getSessionCount()print("Currently \(count) sessions")
For a non-realtime channel (isRealtime: false), no watch stream delivers updates automatically. Call syncChannel to refresh the count on demand; it returns the latest session count:
let count = try await client.syncChannel(viewerChannel)
The session count is automatically updated when clients connect or disconnect from a realtime channel.
Sessions are connection-based and managed for scalability. In abnormal cases (such as app crashes or network partitions), a client might not detach cleanly, so the server may keep the session counted until it is considered stale and cleaned up. This means the displayed count can be approximate and may lag briefly. Avoid using session counts as a source of truth for critical business logic such as billing, authorization, or settlements.
Handling Channel Errors
Subscribe to authentication and sync errors to surface an error state in your UI. A subsequent successful event on the channel implies recovery — there is no separate "recovered" event.
channel.subscribeAuthError { event in// event.reason, event.method}channel.subscribeSyncError { event in// event.error, event.method}
You can also observe every channel event in one place with subscribeAll { event in ... }, switching on event.type (ChannelEventType).
Detaching a Channel
When you're done with a channel, detach it to clean up resources and stop receiving messages:
try await client.detachChannel(channel)
This is a local cleanup — the server reclaims the session automatically once heartbeats stop.
Use Cases
Channels are ideal for:
- Chat applications: Real-time messaging without persistence
- Live notifications: Temporary alerts and updates
- Presence indicators: Showing who's online or in a room
- Cursor sharing: Broadcasting cursor positions in collaborative tools
- Event broadcasting: Sending signals or triggers between clients
For persistent data that needs to be synchronized and stored, use Document instead.
Logger Options
The Logger outputs SDK events to the console for debugging purposes. To modify these options, use the Logger.logLevel variable.
Logger.logLevel = .error
The available log levels for setLogLevel are:
| LogLevel | Description |
|---|---|
LogLevel.Trivial | Most verbose level, displays all logs |
LogLevel.Debug | Detailed information for debugging |
LogLevel.Info | General information |
LogLevel.Warn | Warnings and potential issues |
LogLevel.Error | Errors and unexpected behavior |
LogLevel.Fatal | Critical errors, may lead to termination |
Adjust the log level for flexible control over log verbosity in your application.
Performance Best Practices
- Use
SyncMode.realtimePushOnlyfor write-heavy operations - Batch updates within single
doc.update()call - Detach documents when switching contexts
- Use
Logger.logLevel = .errorin production
Common Use Cases
Real-time Collaborative Text Editing
// Initialize texttry await doc.update { root, _ inroot.content = JSONText()}// Handle text changestry await doc.update { root, presence in(root.content as? JSONText)?.edit(0, 0, "Hello")// Share cursor positionif let range = try? (root.content as? JSONText)?.indexRangeToPosRange((0, 5)) {presence.set(["selection": ["from": range.0, "to": range.1]])}}
Shared Counter
try await doc.update { root, _ inroot.likes = JSONCounter(value: Int64(0))}// Increment likestry await doc.update { root, _ in(root.likes as? JSONCounter<Int64>)?.increase(value: 1)}
Live Cursor Tracking
// Update cursor positiontry await doc.update { root, presence inpresence.set(["cursor": ["x": 100, "y": 200]])}// Subscribe to others' cursorsawait doc.subscribePresence(.others) { event inif event.type == .presenceChanged {// Update UI with new cursor positions}}
Troubleshooting
Document Not Syncing
- Verify
SyncModeis set to.realtime - Check network connection using
subscribeConnection() - Ensure client is properly activated
Presence Not Updating
- Subscribe to presence events before attaching document
- Verify presence data is JSON serializable
- Check that document is attached to client
Memory Issues
- Detach unused documents
- Set appropriate
Logger.logLevel - Monitor document size and consider splitting large documents
Reference
For details on how to use the iOS SDK, please refer to iOS SDK Reference.