Outbox Architecture: Designing Against Message Loss

The Challenge: Instant UI Clear Without Losing Messages

In Simple Memo (Captio-style), the UI clears the instant you tap send, letting you immediately start writing your next memo. This is the core of the Captio-style UX.

But real-world networks have 300ms-2s latency. They fail. They go offline entirely. Most apps wait for a network response before clearing the UI. Safe, but terrible UX. Clear immediately? You risk losing the message.

The Outbox pattern solves both simultaneously. The moment you tap send, the message is persisted to encrypted local storage (the Outbox), the UI clears instantly, and background network delivery begins. On success, the message is removed from the Outbox. On failure, automatic retry kicks in. The result: unsent messages are kept and retried, with near-zero perceived latency.

Outbox Pattern Design Flow

Here is the complete flow from tapping send to confirmed delivery.

  1. Tap Send → Persist to Outbox with AES-GCM Encryption
    The instant the user taps send, the message is encrypted with AES-GCM 256-bit and persisted to local storage. The UI does not clear until this step completes. Outbox persistence is the first line of defense against message loss.
  2. Clear UI Immediately (User Can Start Writing Next Memo)
    Once Outbox persistence succeeds, the text view clears and the send animation plays. From the user's perspective, the message has been "sent." No network response has been waited for. Send-to-Reset target: no noticeable wait (0.25s with animation).
  3. Background Send via Relay API
    In parallel with the UI clear, SendManager fires an HTTP request to the Relay API on Cloudflare Workers. This is fully asynchronous and never blocks the main thread.
  4. On Success: Remove from Outbox
    When the Relay API returns a 200 response, the message is safely deleted from the Outbox. The message lifecycle is complete.
  5. On Failure: Automatic Retry
    For network or server errors, the message stays in the Outbox and is sent again when the connection returns, when you open the app, or from a background task (BGTaskScheduler; iOS decides when it runs). Automatic retries have a limit.
  6. If Offline: NWPathMonitor Detects Connectivity and Auto-Resends
    If the device is offline at send time, Network.framework's NWPathMonitor watches for connectivity changes; when it detects that Wi-Fi or cellular is back, the unsent messages in the Outbox are sent again automatically (as in step 5, automatic retries have a limit).

SendManager Code

The SendManager's send() function is the entry point for the Outbox pattern. It persists to the Outbox first, queues if offline, and sends immediately if connected.

func send(message: String, completion: @escaping (SendResult) -> Void) {
    // Persist to Outbox first (so a crash or lost connection does not drop it)
    let outboxMessage: OutboxMessage
    do {
        outboxMessage = try OutboxManager.shared.add(body: message)
    } catch {
        DispatchQueue.main.async { completion(.failure(error)) }
        return
    }

    // If no network, notify that message has been queued
    guard NetworkMonitor.shared.isConnected else {
        BackgroundTaskManager.shared.scheduleRetryTask()
        DispatchQueue.main.async { completion(.queued) }
        return
    }

    // Execute the send
    performSend(message: message, outboxId: outboxMessage.id, ...)
}

The critical point: OutboxManager.shared.add(body:) writes synchronously to persistent storage. Once this call succeeds, the message is safely stored on the device. Regardless of what happens with the network send, the message remains in the Outbox until explicitly deleted after confirmed delivery.

AES-GCM Encryption Implementation

Messages stored in the Outbox are private user memos. Even on local storage, plaintext is unacceptable. We implemented encryption using Apple CryptoKit's AES-GCM, with no third-party SDK in the encryption path.

  • Apple CryptoKit — Available from iOS 13+. No external dependencies.
  • 256-bit Symmetric Key — Stored in Keychain.
  • Authenticated Encryption — AES-GCM guarantees both confidentiality and integrity. Tampered data is detected on decryption.
enum OutboxEncryption {
    private static var encryptionKey: SymmetricKey {
        if let existingKey = KeychainHelper.load(key: "outbox_encryption_key") {
            return SymmetricKey(data: existingKey)
        }
        let newKey = SymmetricKey(size: .bits256)
        let keyData = newKey.withUnsafeBytes { Data($0) }
        KeychainHelper.save(key: "outbox_encryption_key", data: keyData)
        return newKey
    }

    static func encrypt(_ plainText: String) throws -> Data {
        guard let data = plainText.data(using: .utf8) else {
            throw OutboxError.encryptionFailed
        }
        let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
        guard let combined = sealedBox.combined else {
            throw OutboxError.encryptionFailed
        }
        return combined
    }
}

The key is generated automatically the first time it is needed and stored in Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: other apps cannot read it, it does not move to another device through a backup, and background tasks can read it once the device has been unlocked after a restart. The code above is a simplified version from when this article was written; the current code does not treat a failed Keychain read as a missing key, so it never creates a new key in that case.

Keeping the Data Path Free of External SDKs

Every framework used in the Outbox architecture is Apple-native.

  • CryptoKit — AES-GCM encryption. No third-party crypto libraries needed.
  • Network.framework — NWPathMonitor for connectivity monitoring. No Reachability library needed.
  • BackgroundTasks — BGTaskScheduler for background retry.

Keeping external libraries out of the Outbox path means:

  • No third-party breakage — The Outbox path cannot stop working because a third-party library changed or was discontinued.
  • Security — No third-party code touches the memo text in the Outbox path (encryption, storage and sending).

The app as a whole does use third-party SDKs (Google Sign-In, Firebase and AppsFlyer; see Privacy-First Design). “Apple-native only” here refers to the Outbox data path.

Decoupling UI Clear from Network Send

The true value of the Outbox pattern is the complete separation of the UI layer and the network layer. Let's look at the send button tap handler.

@objc private func sendButtonTapped() {
    PerformanceLogger.shared.beginSendToReset()
    let message = textView.text ?? ""
    isSending = true
    sendBarButton.isEnabled = false

    // Animation → UI clear (does NOT wait for network!)
    performSendAnimation {
        self.clearTextView()
        PerformanceLogger.shared.endSendToReset()
    }

    // Send is async. Does not block UI.
    SendManager.shared.send(message: message) { [weak self] result in
        self?.isSending = false
        self?.updateSendButtonState()
        switch result {
        case .success:
            PerformanceLogger.shared.logEvent(name: "SendSuccess")
        case .failure(let error):
            self?.handleSendError(error)
        case .queued:
            self?.showQueuedFeedback()
        }
    }
}

performSendAnimation and SendManager.shared.send execute completely independently. The animation completes in 0.25 seconds and the text view clears. The network send proceeds asynchronously in the background, never blocking the UI.

Send-to-Reset target: no noticeable wait. Even with animation, 0.25 seconds. The user can start writing their next memo immediately after tapping send. This is the Captio-style UX.

FAQ

Q. What happens to memos sent offline?

They are encrypted and stored in the Outbox, and sent again automatically when connectivity returns (detected by NWPathMonitor), when you open the app, or from a background task. Automatic retries have a limit; for a memo that reaches it, long-press it in History and choose Resend.

Q. Are memos lost if I close the app?

No. The Outbox uses persistent storage. Messages survive app termination and iPhone restarts. They are automatically retried on next app launch or via background tasks.

Q. Why AES-GCM?

Apple CryptoKit provides native AES-GCM support, offering authenticated encryption that detects data tampering during decryption. The encryption code path is entirely first-party — no third-party SDK touches the security-critical code.

Q. How many retries on failure?

The app sends it again automatically when the connection returns, when you open the app, or from a background task (BGTaskScheduler; iOS decides when it runs). A failed send stays in the Outbox instead of being dropped, but automatic retries have a limit.

Related Articles

References