All writing

Designing the Native Bridge for a Collaborative Web Editor

How we treated the JavaScript bridge as a protocol boundary between an iOS SDK and a real-time document engine

The Editor Was Not Native

Our Document SDK looked native from the outside. It participated in the host application’s navigation, account lifecycle, permissions, offline storage, analytics, and system UI. But the editor itself was a large web application packaged with the client and rendered inside WKWebView.

That choice was deliberate. A collaborative editor is not just a text view with a toolbar. It contains a document model, selection semantics, input method handling, block rendering, comments, presence, undo and redo, and a real-time consistency engine. Keeping that core in JavaScript allowed the editor behavior to remain aligned across desktop, web, and mobile.

Native still owned the parts that had to behave like the rest of the application:

  • account and organization context;
  • authentication and token refresh;
  • application navigation and deep links;
  • attachment capture, upload, and download;
  • connectivity, backgrounding, and memory pressure;
  • offline persistence and recovery;
  • system menus, keyboard coordination, and accessibility integration;
  • logging, analytics, and performance traces.

The difficult part was not embedding a web view. It was designing the boundary between two runtimes that each owned important state.

It Was Not React Native Either

It was tempting to call any JavaScript-driven mobile UI “React Native,” but the runtime model was different.

In the React Native architecture of that period, JavaScript described a tree of native views. Calls crossing the bridge were asynchronous, serializable, and batched. The renderer converted React updates into mutations that native UI managers eventually applied to UIView instances.

Our editor used JavaScript in a different way. WebKit owned the DOM, layout, text input integration, and rendering pipeline. From UIKit’s perspective, the editor was one WKWebView, not a tree of native text and block components.

React Native
JavaScript state → React reconciliation → bridge → native view mutations

Web editor
JavaScript state → DOM/layout/rendering inside WebKit
                 ↕
          domain bridge
                 ↕
       native application services

The React Native comparison was still valuable because it exposed the same fundamental costs:

  • every cross-runtime call has scheduling overhead;
  • serialized payloads are copied rather than shared;
  • asynchronous calls cannot safely pretend to be synchronous;
  • a flood of small messages creates queue pressure;
  • ordering, cancellation, and lifetime must be explicit.

Understanding the bridge meant understanding where JavaScript executed, which thread eventually touched UIKit, who owned rendering, and which state could become stale before an asynchronous response returned. Borrowing the name without understanding those mechanics would have produced the wrong abstraction.

A Bridge Is a Protocol, Not a Collection of Callbacks

The first version of a hybrid feature often starts with methods such as:

func openLink(_ url: String)
func editorDidChange(_ json: String)
func uploadImage(_ payload: [String: Any])

This works until the editor grows. Then method names become an undocumented API, payload shapes drift between JavaScript and Swift, callbacks arrive after a document has closed, and every feature invents its own error handling.

We treated the bridge as a versioned protocol instead.

type BridgeEnvelope = {
  protocolVersion: number
  sessionID: string
  sequence: number
  id?: string
  kind: 'request' | 'response' | 'event'
  method: string
  payload?: unknown
  error?: {
    code: string
    message: string
    retryable: boolean
  }
}

Every message carried:

  • a protocol version for compatibility;
  • a session ID to reject callbacks from an old document or account;
  • a monotonic sequence number for ordering and diagnostics;
  • a request ID when a response was expected;
  • a stable method name and schema-validated payload;
  • a structured error rather than an arbitrary JavaScript exception string.

The protocol began with a handshake:

Native did not assume that loading the HTML meant the editor was ready. JavaScript did not assume that every host supported every capability. The handshake negotiated the actual environment before either side sent business commands.

Keep the Bridge at the Domain Level

The most important design decision was deciding what should not cross the bridge.

We did not mirror DOM events or expose arbitrary native selectors. The protocol used domain commands and domain events:

Native to JavaScriptJavaScript to Native
document.openeditor.ready
document.restoredocument.changed
appearance.updateselection.changed
connectivity.updatecollaboration.stateChanged
permission.updatepermission.requested
lifecycle.suspendattachment.uploadRequested
lifecycle.resumenavigation.openRequested

Keystrokes, cursor painting, DOM mutations, and transformed collaboration operations stayed inside the editor runtime. Native received durable checkpoints, user-visible state, and requests for host capabilities.

That separation mattered for both performance and ownership. If every input event crossed into Swift, the bridge would become part of the editing hot path. If native tried to interpret editor operations, the same document logic would exist in two languages and eventually diverge.

The bridge was the control plane. The editor and collaboration engine remained the data plane.

Make Asynchrony Impossible to Ignore

WKScriptMessageHandler delivers messages from JavaScript to native. Native can invoke JavaScript through evaluateJavaScript. Both APIs make crossing the boundary easy, but neither provides a complete RPC system.

Our wrapper added the missing semantics:

  • one response for each request ID;
  • explicit timeouts and cancellation;
  • rejection of duplicate responses;
  • session invalidation when a document closes;
  • schema validation before dispatch;
  • queue ownership for every handler;
  • structured tracing across both runtimes.
struct BridgeRequest<Payload: Encodable>: Encodable {
    let protocolVersion: Int
    let sessionID: String
    let sequence: UInt64
    let id: String
    let method: String
    let payload: Payload
}

final class EditorBridge {
    private weak var webView: WKWebView?
    private let sessionID: String
    private let encodingQueue: DispatchQueue
    private var nextSequence: UInt64 = 0
    private var pending: [String: PendingCall] = [:]

    func send<Payload: Encodable>(
        method: String,
        payload: Payload,
        completion: @escaping (Result<BridgeValue, BridgeError>) -> Void
    ) {
        // Encode off the main thread, register the timeout, then invoke
        // JavaScript on the main thread with a validated JSON envelope.
    }
}

The main thread was an execution constraint, not a place to encode large snapshots. Serialization and validation happened away from the UI thread. Only the final WebKit call and native UI work returned to the main queue.

We also avoided synchronous-looking wrapper APIs. A method that crossed runtimes always completed asynchronously, even when a test implementation could answer immediately. This prevented production timing from violating assumptions created in tests.

Ordering and Backpressure Were Product Requirements

Not every event deserved the same delivery policy.

A permission revocation, document commit, or lifecycle transition could not be dropped. Selection changes, cursor geometry, scroll positions, and analytics samples could be coalesced. Presence updates could expire instead of waiting behind durable work.

We classified messages by semantics:

ClassExamplesPolicy
Durablecommit, checkpoint, permission changeOrdered, acknowledged, never silently dropped
Session controlopen, close, suspend, resumeOrdered and guarded by session ID
Interactiveselection, toolbar state, viewportLatest value wins
Ephemeralcollaborator cursor, typing presenceBest effort with expiry
Diagnostictiming marks, countersBatched and sampled

This kept a fast typist or a large collaborative session from filling the bridge queue with state that was already obsolete.

Backpressure also forced us to define ownership. The sender did not continue producing durable messages indefinitely when the receiver fell behind. It either waited for acknowledgment, merged compatible operations, or persisted them for later recovery.

Real-Time Editing Starts With Local Responsiveness

A collaborative editor cannot wait for the server before showing a character. Local input must update the local document immediately, then travel asynchronously to other replicas.

That optimistic model creates the central problem. Two users can produce operations against the same revision:

Revision 42: "AC"

User A inserts "B" at index 1  → "ABC"
User B deletes "C" at index 1  → "A"

Applying the original indexes in arrival order can delete or insert the wrong content. The system must preserve convergence and, as far as possible, the intention behind each operation.

The collaboration model we designed around had the shape of a centralized Operational Transformation system: versioned operations, immediate local application, a server acknowledgment, and transformation of concurrent operations. It belonged to the same algorithmic family as the early groupware work by Ellis and Gibbs and the Jupiter collaboration system.

The important word is shape. A production rich-text engine is not a textbook implementation over a plain string. It is an engineering variant built around the product’s document model.

The Jupiter-Style Client State Machine

The client could be understood through three states:

At most one operation was awaiting server confirmation. Additional local edits were composed into a buffer.

When a remote operation arrived while a local operation was outstanding, the client transformed the two operations:

(local′, remote′) = transform(local, remote)

apply(remote′) to the local document
keep local′ as the outstanding operation

The transformed operations described the same user intentions in the new document context. If a buffered operation also existed, the remote operation had to be transformed through both the outstanding operation and the buffer, while the local operations were updated in the opposite direction.

On the server, an operation submitted against an older base revision was transformed through the operations accepted since that revision. Once accepted, the server advanced the revision, acknowledged the sender, and broadcast the transformed operation.

This architecture provided several useful properties:

  • local input remained immediate;
  • the server established a canonical operation order;
  • clients could identify exactly what had and had not been acknowledged;
  • reconnecting clients could reason from a known revision;
  • convergence did not require locking the document while another person typed.

Rich Text Makes Transformation a Domain Problem

Plain-text OT examples usually operate on retain, insert, and delete. A production document contains much more:

  • paragraphs and headings;
  • lists and nested indentation;
  • mentions, links, and inline styles;
  • tables and embedded content;
  • comments and annotations;
  • block splits and merges;
  • permissions and read-only transitions.

Transforming positions alone was not enough. The operation model needed stable block identities and semantic commands such as:

type DocumentOperation =
  | { type: 'insertText'; blockID: string; offset: number; text: string }
  | { type: 'deleteRange'; blockID: string; start: number; end: number }
  | { type: 'splitBlock'; blockID: string; offset: number; newBlockID: string }
  | { type: 'mergeBlocks'; leadingID: string; trailingID: string }
  | { type: 'setAttributes'; targetID: string; patch: AttributePatch }
  | { type: 'insertEmbed'; parentID: string; index: number; embed: Embed }

Each pair of concurrent operation types needed defined transformation behavior. Splitting a block while another user inserted text, deleting a block while someone edited an attribute, or moving content while a comment range changed were product semantics, not generic string mathematics.

This was why the collaboration engine belonged beside the editor model in JavaScript. The renderer and consistency engine shared the same understanding of blocks, selections, and composition. Reimplementing those rules inside the native SDK would have doubled the most fragile part of the system.

Input Methods, Undo, and Presence Needed Separate Semantics

The editor also had to distinguish between changes that looked similar at the transport layer.

Input method composition

For Chinese, Japanese, and other composed input, intermediate marked text is not a committed user operation. Emitting every composition update as a collaborative edit can corrupt the candidate sequence and move the selection unexpectedly. The editor waited for composition to commit before producing durable operations.

Undo and redo

Undo in a collaborative editor cannot mean “restore the previous global document.” It should undo the current user’s intention after transforming it through remote operations that arrived later. Local history therefore tracked authored operations, not snapshots of the entire shared document.

Presence and cursors

Collaborator cursors and selections were revision-aware but ephemeral. They could be transformed for display, yet they did not belong in durable document history. Losing one presence update was acceptable; losing a content operation was not.

Separating these semantics prevented one generic event stream from imposing the strongest durability requirement on every message.

The Bridge Should Not Become the Collaboration Algorithm

The native SDK still had important responsibilities around collaboration:

  • supplying credentials and refreshed tokens;
  • reporting connectivity and application lifecycle;
  • persisting snapshots and pending operations;
  • restoring state after termination or a WebKit process crash;
  • coordinating account and document-session teardown;
  • exposing collaboration health to host diagnostics.

But it did not transform editor operations.

The bridge transported lifecycle and persistence boundaries. The collaboration engine owned consistency. That kept the algorithm identical across clients that shared the web editor and kept native changes from affecting document convergence.

Offline Recovery Was a Session Problem

Offline support introduced two histories:

  1. the latest server revision known to the client;
  2. the local operations produced after that revision.

Native persistence stored a checkpoint containing both. Restoring only rendered HTML would recover the appearance but lose the information needed to merge later. Restoring only operations would require replaying an unbounded history.

The checkpoint therefore combined:

type EditorCheckpoint = {
  documentID: string
  accountID: string
  baseRevision: number
  snapshot: Uint8Array
  pendingOperations: DocumentOperation[]
  editorSchemaVersion: number
  createdAt: number
}

On reconnect, the editor fetched operations or a newer snapshot from the server, transformed or rebased the local queue, and resumed sending from a known revision. If divergence exceeded the supported window, it fell back to a fresh canonical snapshot and replayed recoverable local intent.

The account ID, document ID, schema version, and session ID were all required. A perfectly valid checkpoint for the wrong account was still corrupt state.

Performance Had to Be Measured Across Both Runtimes

Hybrid performance problems often disappear into the boundary. Native sees a web view that is slow to become ready; JavaScript sees bootstrap data that arrived late. Neither side can explain the complete timeline alone.

We used one trace ID across the opening sequence:

native start
→ web view acquired
→ local bundle loaded
→ bridge handshake
→ checkpoint restored
→ document model ready
→ first blocks rendered
→ editor interactive
→ collaboration connected

Several optimizations followed directly from that model:

  • package frontend assets locally so loading and parsing can be prepared before navigation;
  • prewarm a bounded WebView pool rather than constructing on demand;
  • render only the initial visible block range;
  • keep whole-document snapshots off the interactive bridge path;
  • send deltas or checkpoints instead of repeated full content;
  • batch diagnostics and coalesce interactive state;
  • treat process memory as a shared budget with the host, not free WebKit capacity.

Preloading was not automatically good. A prewarmed editor consumed memory, CPU, and WebKit process resources even if the user never opened a document. The SDK needed a host-aware policy for when to prepare, retain, downgrade, or discard that work.

Security Was Part of the Protocol

A JavaScript bridge is an authority boundary. A generic “invoke native method by name” API would allow any loaded script to reach capabilities that were never intended for it.

We used a narrow allowlist and validated every envelope:

  • only the bundled editor origin could use the bridge;
  • navigation outside the editor was intercepted;
  • methods were registered explicitly;
  • payloads were decoded into typed schemas;
  • document and account identifiers were checked against the active session;
  • privileged actions repeated native permission checks;
  • attachment URLs and file paths were never trusted from JavaScript;
  • message handlers were removed when the session ended.

WKUserContentController also retains its script message handlers. Registering a view controller directly can create a lifetime cycle, so the bridge used a weak forwarding handler and explicit teardown.

The editor could request an action. Native remained responsible for deciding whether that action was allowed.

What We Learned

The strongest bridge was not the one with the most native methods. It was the one that made the boundary small, typed, asynchronous, and observable.

Understanding React Native’s original bridge helped us recognize the cost of serialization, batching, and cross-thread scheduling. Understanding WebKit prevented us from applying the wrong rendering model. Understanding Operational Transformation showed why local responsiveness, revision order, acknowledgments, buffering, and intention preservation had to be designed together.

The resulting architecture had three distinct authorities:

  • Native owned the host environment and durable application lifecycle.
  • The web editor owned rendering, input semantics, and the document model.
  • The collaboration engine owned versioned operations and convergence.

The JSBridge connected those authorities without collapsing them into one another.

That was the real SDK capability. It was not simply displaying a web editor inside a native application. It was making two runtimes and a distributed editing system behave like one coherent product.

References