[Dev Log Day 1] Loved Captio So Much, I Had to Recreate That Experience

Have you ever heard of an app called Captio?

You write a memo, tap the send button. That's it -- your memo arrives in your email inbox. The keyboard appeared the instant you launched it. You wrote, you sent, it cleared. No extra features whatsoever. That was all the app did.

But that "all it did" was extraordinarily comfortable.

An idea on the commuter train, a task that popped into your head during a meeting, a thought before falling asleep -- Captio delivered all of them to your inbox within a second. No opening a notes app, choosing a folder, typing a title... none of that friction. Once you experienced that "zero friction" workflow, you couldn't let it go.

Then Captio shut down.

I tried several alternatives, but none came close to that experience. Slow launch times, unnecessary UI elements, memos lingering after send -- these might seem like minor differences, but the essence of what Captio had built was precisely the accumulation of these "minor things."

So I decided to build it myself. The Captio experience, once more.

This is Day 1 of the "Simple Memo (Captio-style)" development diary. In this article, I'll deconstruct the Captio experience and document every technical decision made to recreate it with a modern tech stack.

1. What Made Captio So Good -- Deconstructing the Experience

To articulate the Captio experience, I broke it down into three elements.

Element 1: Instant Launch-to-Input

When you launched Captio, the keyboard appeared instantly and text input was ready. There was no splash screen followed by a home screen where you then tap a "compose" button. Launch equals input. We call the time from launch to text input readiness Time-to-Text. Captio's felt like under 0.5 seconds.

Element 2: Instant Clear After Send

When you tapped send, the screen cleared immediately. No staring at a "Sending..." progress bar. The moment your memo disappeared, you felt the reassurance of "it's sent" and moved on. We call the time from tapping send to UI clearing Send-to-Reset. Our target is under one-tap.

Element 3: Nothing Unnecessary

Folders, tags, rich text, markdown support -- Captio had none of these. Just a text field and a send button. This decisiveness brought cognitive load close to zero. An app where you never have to think about "what should I do." That was Captio.

Recreating these three elements with modern iOS development. That's the mission of "Simple Memo (Captio-style)."

2. Architecture: Why UIKit Instead of SwiftUI

Choosing UIKit over SwiftUI in 2026 iOS development might seem unorthodox. But our top priority was clear -- Time-to-Text under 500ms.

SwiftUI's `body` re-evaluation, `StateObject` initialization, `@Environment` resolution -- these overheads are negligible in typical app development, but not when you need "instant launch-to-input." We wanted to focus the text field even 1ms faster.

So we adopted direct UIKit construction without Storyboard.

We create the window in SceneDelegate and set ComposeViewController directly as the rootViewController. We even skip Storyboard parsing.

// SceneDelegate.swift
func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = ComposeViewController()
    window.makeKeyAndVisible()
    self.window = window
}

Then in ComposeViewController's `viewDidAppear`, we immediately focus the text view.

// ComposeViewController.swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    textView.becomeFirstResponder()
}

These two pieces of code alone create the shortest path from launch to keyboard display.

Real device measurements: 200-300ms. Well below our 500ms target. Trying to achieve the same with SwiftUI, we found that `@FocusState` delays and body re-evaluation timing made it difficult to consistently stay under 500ms.

UIKit may be "old." But it's "fast." On this single point, UIKit remains the best choice.

3. Achieving "Send and Clear" in a Single Tap

To recreate Captio's "send and it's gone" experience, the most critical design decision was to completely decouple the send animation and UI clear from the network response.

A typical app's send flow looks like this:

  1. Tap the send button
  2. Show a loading indicator
  3. Wait for the server response
  4. Clear UI on success, show error on failure

In this flow, network latency directly becomes UI latency. Our flow works differently:

  1. Tap the send button
  2. Immediately clear the UI (animation: 0.25s)
  3. Send via network in the background
  4. Success: silently remove from Outbox / Failure: retry in background
// ComposeViewController.swift
private func performSendAnimation() {
    UIView.animate(withDuration: 0.25,
                   delay: 0,
                   options: .curveEaseOut) {
        self.textView.alpha = 0
        self.subjectField.alpha = 0
    } completion: { _ in
        self.textView.text = ""
        self.subjectField.text = ""
        self.textView.alpha = 1
        self.subjectField.alpha = 1
        self.textView.becomeFirstResponder()
    }
}

For the user, the moment they tap send, the memo "vanishes" and they're ready to write the next one. Network success or failure is handled invisibly in the background.

"But what if the send fails?" -- that question is answered by the Outbox architecture in the next section.

4. Zero Message Loss: The Outbox Architecture

Decoupling the send UI from the network response means that "the UI cleared but the email actually failed to send" could happen. This is absolutely unacceptable. A user thinking they sent a memo that was never actually delivered -- this is a fatal loss of trust for a memo app.

That's why we adopted the Outbox pattern.

The message send flow works as follows:

  1. Tap send: Save the message to the Outbox (local storage) with AES-GCM encryption
  2. UI clear: performSendAnimation immediately clears the screen
  3. Background send: Retrieve the message from the Outbox and send to the Relay API
  4. Success: Delete the message from the Outbox
  5. Failure: Retry with exponential backoff (1s, 2s, 4s, 8s...)
  6. Offline: Monitor connectivity with NWPathMonitor, auto-resend when restored

The critical point is that the message is saved to the Outbox before the network send. This means even if the app crashes suddenly or the device loses power, the message is never lost.

Messages stored in the Outbox are protected with AES-GCM encryption using CryptoKit. Encryption keys are stored in the Keychain, inaccessible from outside the app's sandbox.

// OutboxManager.swift
func enqueue(message: OutboxMessage) throws {
    let key = try KeychainHelper.getOrCreateSymmetricKey()
    let sealedBox = try AES.GCM.seal(
        message.plainData,
        using: key
    )
    let encrypted = EncryptedOutboxEntry(
        id: message.id,
        sealedData: sealedBox.combined!,
        createdAt: Date(),
        retryCount: 0
    )
    try persistenceStore.save(encrypted)
}

Furthermore, everything is implemented in-house, resulting in zero external library dependencies. CryptoKit, NWPathMonitor, URLSession -- all Apple-native frameworks. Being free from third-party library version management and security audits is a significant advantage for a small team.

5. Relay API: Breaking Free from External Email API Dependency

Captio's sending mechanism was never officially documented, but it is believed to have relied on sending mail through an external provider from the device. In general, when an external email API's OAuth requirements change and access restrictions pile up, this kind of approach tends to become unsustainable.

We chose a Cloudflare Workers + Resend API architecture.

Cloudflare Workers is an edge computing platform where code runs at the data center closest to each user. Requests from Japan are processed at Japan's edge, requests from the US at the US edge. This virtually eliminates the cold start problem compared to traditional serverless platforms (AWS Lambda, etc.).

For email delivery, we use the Resend API. Resend is a developer-friendly email API offering high deliverability and reliability.

Email Verification Flow

When users set their destination email address, a 6-digit verification code is sent for email verification. This prevents unauthorized sending to third-party email addresses.

Multi-Layer Rate Limiting

To prevent abuse, we implement multi-layer rate limiting.

// Cloudflare Worker - Rate Limit Configuration
const RATE_LIMITS = {
  devicePerMinute: 30,    // 30 per minute per device
  devicePerDay: 200,      // 200 per day per device
  ipPerHour: 120,         // 120 per hour per IP
  globalPerDay: 300       // 300 per day globally (adjustable)
};

By applying rate limits at three layers -- device, IP, and global -- we can block both single device runaway and botnet attacks.

Idempotency Guarantee

To prevent duplicate sends due to network instability, each message is assigned a UUID, and the Relay API performs duplicate checking. If the same UUID is resent, subsequent attempts return a success response without actually sending the email. Combined with the Outbox pattern's retry mechanism, this ensures users never receive the same memo twice.

6. Today's Work: Eliminating Small Annoyances One by One

Much of Day 1's implementation work was spent redesigning the star rating dialog. App Store ratings are an app's lifeline, but you shouldn't ask for ratings in a way that damages the user experience.

Redesigned Display Conditions

The star rating dialog display conditions were set as follows:

  • Shows every 100 sends: Only displayed to users who have used the app extensively
  • Minimum 14-day interval: At least 14 days must have passed since the last display
  • 30 days after dismiss: If the user dismisses it, it won't reappear for 30 days

Star Icon Tap Area Fix

Apple's Human Interface Guidelines recommend a minimum touch target size of 44x44pt. The star icon tap areas didn't meet this standard, so we fixed it.

// StarRatingView.swift
private func createStarButton() -> UIButton {
    let button = UIButton(type: .custom)
    button.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
        button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
    ])
    return button
}

Star Icon Distortion Fix

Star icons were being distorted due to UIStackView's `.fillEqually` distribution. Fixed by setting `contentMode` to `.scaleAspectFit` and changing the StackView distribution to `.equalSpacing`.

Post-Rating Flow

  • 5 stars: Navigate to the App Store review screen (`SKStoreReviewController`)
  • 4 stars and below: Quietly display a "Thank you" toast and close. Don't funnel negative reviews to the App Store.

7. Fixing History Status Display Inconsistencies

In the send history screen (History), I noticed a bug. Some messages showed "sending" status indefinitely even though delivery had already completed.

After investigating, I found the issue in the status update logic. The previous implementation used the message's text content as the key for status updates. However, when the same memo content is sent multiple times, text-based lookup can't identify the correct message.

The fix was straightforward. We changed from text-based lookup to ID-based status updates. Since each message is assigned a UUID when saved to the Outbox, we use this ID to update History status.

// HistoryManager.swift
// Before: Text-based lookup (cause of the bug)
// func updateStatus(forText text: String, status: SendStatus)

// After: ID-based status update
func updateStatus(forMessageId id: UUID, status: SendStatus) {
    guard let index = entries.firstIndex(where: { $0.messageId == id }) else {
        return
    }
    entries[index].status = status
    persistEntries()
}

With this fix, even when sending the same memo content multiple times, each message's status is displayed correctly.

8. An Obsessive Commitment to Privacy

Private information gets written into memo apps. Passwords, personal concerns, business ideas -- we can't predict what users will write. That's exactly why privacy protection should be not just "adequate" but "excessive" -- that level is just right.

Privacy Overlay in App Switcher

iOS's app switcher shows app screens as thumbnails. To eliminate the risk of third parties seeing memo content, we display a privacy overlay when the app transitions to the background.

Ephemeral URLSession

A standard URLSession saves cache, cookies, and authentication data to disk. We use `URLSessionConfiguration.ephemeral` to ensure none of this information persists on disk. This eliminates any risk of memo content remaining as cached data in storage.

Sanitized Logging

Debug logs during development require careful attention. A seemingly harmless practice like "logging the content of sent memos" can become a security hole. Our policy is clear:

  • Memo content is never logged, even in DEBUG builds
  • Error logs use only `type(of: error)`, never `localizedDescription`
  • Reason: `localizedDescription` may contain user input
// NetworkManager.swift
// BAD: Logging error details
// Logger.error("Send failed: \(error.localizedDescription)")

// GOOD: Logging only the error type
Logger.error("Send failed: \(type(of: error))")

It might seem paranoid. But to earn users' trust in a memo app, we believe this level of paranoia is necessary.

9. 10 Languages: Reaching Captio Users Worldwide

Captio had users worldwide, centered around English-speaking countries. To reach those former users with "Simple Memo (Captio-style)," we supported 10 languages from the initial release.

Supported languages: Japanese, English, Spanish, French, German, Italian, Portuguese, Korean, Chinese (Simplified), and Arabic.

The technical challenge of Arabic RTL (Right-to-Left) support was particularly notable. Not just text direction, but the entire UI layout needs to be mirrored. By consistently using `leading`/`trailing` constraints in Auto Layout, we achieve a natural layout in RTL environments.

In-App Language Switching

In addition to iOS 16+'s built-in per-app language settings, we implemented our own language switching mechanism. When the language changes, the ViewController is replaced with a `cross-dissolve` transition for a smooth switching experience.

// LanguageManager.swift
func applyLanguageChange() {
    guard let window = UIApplication.shared.connectedScenes
        .compactMap({ $0 as? UIWindowScene })
        .first?.windows.first else { return }

    let newVC = ComposeViewController()
    newVC.view.frame = window.bounds
    UIView.transition(with: window,
                      duration: 0.3,
                      options: .transitionCrossDissolve,
                      animations: {
        window.rootViewController = newVC
    })
}

10. Today's Lesson: Product Design Is About Trust

Finishing Day 1, what strikes me is that the essence of this product is designing for trust.

Let me summarize the technical metrics:

  • Time-to-Text: Under 500ms (measured 200-300ms)
  • Send-to-Reset: Under one-tap
  • Message loss: Zero (Outbox pattern)
  • Encryption: AES-GCM (CryptoKit)
  • External library dependencies: Zero

But all these metrics converge on a single goal: trust.

Fast launch: the trust that "I can always write immediately."
Send and clear: the trust that "it's been processed."
Zero message loss: the trust that "it will absolutely be delivered."
Encryption: the trust that "nobody can see it."

Captio wasn't loved because its features were superior. It was loved because it had meticulously polished an experience you could use with complete trust. That's what we want to carry forward.

11. Day 2 Preview

In Day 2, we plan to tackle the following themes:

  • Send animation refinement: Moving beyond the current 0.25s fade-out to a more pleasant animation
  • History status update timing optimization: Comparing real-time updates vs. batch updates
  • Is the success toast even needed?: Considering the option of "not showing" any send success feedback

Aiming for Captio's "nothing there" decisiveness. On to Day 2.

References