Real-Time Collaborative Text Editing with Kotlin Multiplatform Mobile and SwiftUI
Real‑time collaborative text editing is transforming how teams create documents, code, and notes on the fly. By combining a shared Kotlin backend, Kotlin Multiplatform Mobile (KMM) on the server side, SwiftUI on iOS, and Jetpack Compose on Android, developers can deliver a seamless, low‑latency editing experience across both ecosystems. This article walks through the architecture, technology choices, implementation steps, and best practices to help you build a live editor that keeps everyone on the same page—literally.
Why Real‑Time Collaboration Matters
In today’s remote‑first world, instant feedback and synchronous editing are no longer nice‑to‑have—they’re expected. Whether it’s drafting a marketing copy, debugging code together, or brainstorming ideas, a live editor reduces back‑and‑forth communication and keeps context intact. From a developer standpoint, real‑time collaboration also offers an excellent opportunity to showcase modern cross‑platform solutions and network architectures.
Architecture Overview
The system is split into three main layers: a shared Kotlin backend, platform‑specific UI layers, and a real‑time sync protocol. The backend runs Ktor with WebSocket support, handling document state, conflict resolution, and persistence. On the client side, SwiftUI drives the iOS UI while Jetpack Compose manages the Android UI, both sharing business logic and networking code via KMM. The sync layer uses a lightweight message format (JSON over WebSocket) to broadcast delta changes to all connected clients.
Shared Kotlin Backend
All document logic lives in the backend. By using Kotlin on the server, you keep a single language codebase for data models, serializers, and algorithms. This eliminates duplication and simplifies future feature additions.
Platform‑Specific UIs: SwiftUI & Jetpack Compose
SwiftUI provides a declarative, reactive UI for iOS. Jetpack Compose offers the same for Android. Both frameworks excel at reflecting model changes instantly, making them ideal partners for a real‑time editor.
Real‑Time Sync Layer
WebSockets deliver bi‑directional, low‑latency connections. Messages are small JSON objects containing operations (insert, delete, replace). On receipt, clients apply the operation optimistically, ensuring a responsive feel even when network lag is present.
Choosing the Right Technologies
Every component in the stack serves a specific purpose. Let’s look at the rationale behind each choice.
Kotlin Multiplatform Mobile (KMM)
KMM lets you share business logic, networking, and data models between Android and iOS. Using Kotlin for the backend and the shared module keeps the entire stack consistent.
SwiftUI for iOS
SwiftUI’s TextEditor is lightweight and easy to extend with custom modifiers. Its state management aligns well with KMM’s coroutine‑based networking.
Android UI: Jetpack Compose or XML
Jetpack Compose is the natural choice for a reactive, Kotlin‑centric UI. If your team already uses XML, you can still share logic but the UI will be duplicated.
Real‑Time Backend: Ktor + WebSockets / Firebase Realtime Database / Supabase
For custom logic and conflict resolution, Ktor with WebSockets is ideal. If you prefer a managed service, Firebase Realtime Database or Supabase Realtime can serve as the sync layer, though you’ll need to handle conflict resolution yourself.
Building the Shared Backend
Below is a step‑by‑step guide to creating the Ktor server that will power the editor.
Ktor Server Setup
plugins {
kotlin("jvm") version "1.9.0"
id("io.ktor.plugin") version "2.3.0"
}
dependencies {
implementation("io.ktor:ktor-server-core:2.3.0")
implementation("io.ktor:ktor-server-websockets:2.3.0")
implementation("io.ktor:ktor-server-netty:2.3.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.0")
}
Configure the application to install WebSockets and ContentNegotiation for JSON serialization.
Data Models & Serialization
Define a Document data class and an Operation sealed class representing insert, delete, and replace actions.
data class Document(
val id: String,
var content: String,
var version: Long
)
sealed class Operation {
data class Insert(val pos: Int, val text: String) : Operation()
data class Delete(val pos: Int, val length: Int) : Operation()
data class Replace(val pos: Int, val length: Int, val text: String) : Operation()
}
Use kotlinx.serialization to convert operations to JSON.
WebSocket Endpoints
Each client connects to /ws/doc/{docId}. The server keeps a MutableMap of active sessions per document.
routing {
webSocket("/ws/doc/{docId}") {
val docId = call.parameters["docId"]!!
val doc = DocumentRepository.get(docId)
val sessionId = UUID.randomUUID().toString()
DocumentRepository.addSession(docId, sessionId, this)
// Send initial document
send(
Json.encodeToString(
DocumentUpdate(doc.content, doc.version)
)
)
for (frame in incoming) {
val op = Json.decodeFromString(frame.readText())
DocumentRepository.applyOperation(docId, op)
broadcastToSessions(docId, op)
}
DocumentRepository.removeSession(docId, sessionId)
}
}
Conflict Resolution & Operational Transformation
For a simple editor, linear versioning may suffice. However, to support simultaneous edits from multiple users, implement a lightweight Operational Transformation (OT) algorithm. Store a global version and transform incoming operations against the current state before applying.
Implementing the Android Client
Android’s side relies heavily on KMM to reuse networking and data models.
Project Setup & Gradle
plugins {
id("com.android.application") version "8.0.2"
kotlin("android") version "1.9.0"
id("org.jetbrains.kotlin.multiplatform") version "1.9.0"
}
kotlin {
android()
ios()
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:2.3.0")
implementation("io.ktor:ktor-client-cio:2.3.0")
implementation("io.ktor:ktor-client-websockets:2.3.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2")
}
}
}
}
UI with Jetpack Compose
Create a DocumentScreen that hosts a TextField bound to a DocumentViewModel. Use LaunchedEffect to listen for incoming WebSocket messages.
@Composable
fun DocumentScreen(viewModel: DocumentViewModel = viewModel()) {
val textState = viewModel.text.collectAsState()
TextField(
value = textState.value,
onValueChange = { newText ->
viewModel.onTextChanged(newText)
},
modifier = Modifier.fillMaxSize()
)
}
Connecting to WebSocket
In DocumentViewModel, establish the connection using Ktor’s WebSocketClient and expose a MutableStateFlow for the document content.
class DocumentViewModel : ViewModel() {
private val client = HttpClient(CIO) {
install(WebSockets)
}
private val _text = MutableStateFlow("")
val text: StateFlow get() = _text
init {
viewModelScope.launch {
client.webSocket(host = "your.server.com", port = 8080, path = "/ws/doc/${'$'}docId") {
send(Frame.Text("{\"type\":\"join\"}"))
for (frame in incoming) {
val op = Json.decodeFromString(frame.readText())
applyOperation(op)
}
}
}
}
private fun applyOperation(op: Operation) {
// Simple optimistic application
_text.update { current ->
when (op) {
is Operation.Insert -> current.substring(0, op.pos) + op.text + current.substring(op.pos)
is Operation.Delete -> current.removeRange(op.pos, op.pos + op.length)
is Operation.Replace -> current.substring(0, op.pos) + op.text + current.substring(op.pos + op.length)
}
}
}
fun onTextChanged(newText: String) {
// Diff the newText against _text.value to create an Operation
// Send the Operation to the server
}
}
Handling Edit Events
Use a debounced diff algorithm to reduce the number of messages. When the user stops typing for 300 ms, compute the delta and send an Insert or Delete operation.
Implementing the iOS Client
The iOS implementation mirrors Android but leverages SwiftUI and Combine for reactive updates.
Swift Package Manager Integration
Add KotlinMultiplatformClient as a dependency and generate the shared module.
// Package.swift
.package(url: "https://github.com/yourorg/kmm-client", from: "1.0.0")
SwiftUI TextEditor
Wrap the shared DocumentViewModel in a ObservableObject and bind it to TextEditor.
struct DocumentView: View {
@StateObject var viewModel = SharedDocumentViewModel()
var body: some View {
TextEditor(text: $viewModel.text)
.padding()
.onChange(of: viewModel.text) { newText in
viewModel.onTextChanged(newText)
}
}
}
WebSocket Wrapper
Use URLSessionWebSocketTask to connect to the same endpoint. The shared Kotlin code handles serialization, so the Swift wrapper only needs to forward frames.
Event Handling & UI Updates
When an operation arrives, update the SwiftUI State property. The view re-renders instantly, maintaining a fluid user experience.
Synchronizing Changes
Instant sync is the cornerstone of a real‑time editor. Below are key strategies to keep latency low and state consistent.
Optimistic UI & Local Caching
Apply user edits locally before receiving server confirmation. Store a local copy of the document in Room (Android) or Core Data (iOS) so the editor remains usable offline.
Delta Messages
Transmit only the diff rather than the entire content. Represent deltas as Insert and Delete objects, allowing the server to apply them in order.
Broadcasting to Sessions
The server forwards operations to all connected clients. If a client’s local state diverges, use the OT algorithm to transform pending operations against the server’s version.
Testing & Deployment
Rigorous testing ensures the editor behaves correctly under concurrent use.
Unit Tests for OT
Write unit tests for DocumentRepository.applyOperation and transformOperation to verify that two simultaneous inserts at the same position merge correctly.
Load Testing
Simulate 50–100 concurrent WebSocket connections to a test environment using k6 or Artillery. Measure round‑trip time and CPU usage.
CI/CD Pipeline
Configure a GitHub Actions workflow that builds the Ktor server and deploys to a staging server. For the mobile apps, use Fastlane for iOS and Gradle for Android.
Security & Scalability
Protect user data and prepare for growth.
Authentication
Issue JWT tokens for each user. Verify tokens on the WebSocket handshake and embed them in messages.
Rate Limiting & Throttling
Prevent abuse by limiting the number of operations a client can send per second. Use Ktor’s RateLimit feature or implement custom logic.
Horizontal Scaling
When scaling horizontally, coordinate WebSocket connections using a message broker like Redis Pub/Sub or Kafka. Each server instance subscribes to the document topic and forwards operations to connected clients.
Monitoring & Observability
Deploy Prometheus metrics for the Ktor server and use Firebase Crashlytics for mobile crash reports. Log operation counts and average latency to surface performance bottlenecks.
Conclusion
By leveraging Kotlin for both the backend and shared client logic, and by using SwiftUI and Jetpack Compose for reactive UIs, you can build a robust real‑time collaborative editor. The architecture outlined above balances custom conflict resolution with low‑latency sync, making it suitable for a range of use cases from simple note‑taking apps to enterprise‑grade code editors.
