Outbox Architecture: Zero Message Loss Design

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: zero message loss with near-zero perceived latency.

Outbox Pattern Design Flow

Here is the complete flow from tapping send to guaranteed message 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 for zero 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: one-tap (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 with Exponential Backoff
    For network or server errors, automatic retry with exponential backoff (1s → 2s → 4s → 8s...) kicks in. BGTaskScheduler ensures retries happen even when the app is in the background.
  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. The moment Wi-Fi or cellular returns, all unsent messages in the Outbox are automatically resent.

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 (guarantees zero message loss)
    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, message safety is guaranteed. 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, bound to the device.
  • 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 auto-generated on first launch and securely stored in Keychain. Keychain is tied to the device's Secure Enclave, making it inaccessible to other apps or backups.

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.

Zero external dependencies has significant implications:

  • No Breaking Changes — Third-party libraries won't break on OS updates.
  • Security — No third-party code touches the user's memo data. The entire code path handling user content is first-party.
  • Small Binary — No unnecessary frameworks means minimal download size.
  • Faster Builds — No dependency resolution overhead. CI stays fast.

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: one-tap. 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. When connectivity returns, NWPathMonitor detects it and automatically resends all pending messages. No user action required.

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?

Retries use exponential backoff and are scheduled as background tasks via BGTaskScheduler. They continue until the message is successfully delivered. The system is designed to never drop a user's memo.

Related Articles

References