Back to blog

Building a WatchKit Companion App in Under 500 Lines

What if I told you a fully-featured Apple Watch app needs less than 500 lines of Swift? No compromises on security, no cutting corners on UX.

I recently shipped a WatchKit companion app for a news reader that serves multiple Swiss news outlets. The app lets users browse articles, view details, and bookmark content for later - all synced seamlessly with the iOS app. Along the way, I ran into three interesting challenges that I think are worth sharing.

Alt text

The Architecture

Here’s the entire file structure:

Watch/
├── App.swift              (17 lines)
├── ContentView.swift      (129 lines)
├── DetailView.swift       (131 lines)
├── Connectivity.swift     (107 lines)
├── BookmarkService.swift  (49 lines)
└── Defaults.swift         (58 lines)

That’s it. 491 lines total. The secret? Ruthless focus on what actually matters on a watch: quick glances at headlines, fast loading, and reliable sync with the phone.

The app also serves 7 different news brands through Xcode’s multi-target setup. Each brand gets its own Watch target but shares the core Swift files. Conditional compilation handles the differences.

Challenge #1: Secure Authentication Without AppGroups

Here’s where it gets interesting. The Watch app needs to make authenticated API calls, but it can’t directly access the iOS app’s Keychain. AppGroups could solve this, but they add complexity and require entitlement changes.

Instead, I used WatchConnectivity for on-demand token requests:

// Watch side: request token from iOS
func requestToken(completion: @escaping (String?) -> Void) {
    guard WCSession.default.isReachable else {
        completion(nil)
        return
    }

    WCSession.default.sendMessage(["request": "token"]) { reply in
        let token = reply["token"] as? String
        // Cache it locally for future use
        Keychain.save(token, service: "watch-auth")
        completion(token)
    }
}

// iOS side: respond with fresh token
func session(_ session: WCSession,
             didReceiveMessage message: [String: Any],
             replyHandler: @escaping ([String: Any]) -> Void) {
    Auth.shared.getToken { token in
        replyHandler(["token": token ?? ""])
    }
}

The clever part? I don’t request a new token every time. The Watch caches tokens locally and validates expiration by decoding the JWT. Only when it’s within 12 hours of expiring do I bother the iOS app for a refresh. This saves battery and handles the case where the phone isn’t nearby.

Challenge #2: Responsive Design for Tiny Screens

Apple Watch screens range from 38mm to 45mm. That sounds small, but proportionally it’s a huge difference. Text that looks perfect on a 45mm watch becomes cramped on a 38mm.

My solution was a simple size detection:

var isLargeScreen: Bool {
    screenWidth >= 196
}

Text(article.title)
    .font(.headline)
    .lineLimit(2)
    .minimumScaleFactor(isLargeScreen ? 1.0 : 0.8)
    .layoutPriority(1)

The minimumScaleFactor lets SwiftUI gracefully shrink text on smaller screens instead of truncating it. And layoutPriority ensures the title always wins the space battle against less important elements like timestamps.

This three-line pattern made the difference between an app that feels cramped and one that feels native on every watch size.

Challenge #3: Offline-First Bookmarking

Watches have notoriously flaky connectivity. Users might bookmark an article while on a run, far from their phone. The bookmark needs to “just work” and sync later.

I implemented a local cache with automatic expiration:

struct BookmarkCache {
    static func save(_ articleId: String) {
        var data = load()
        data.items.insert(articleId)
        data.expiresAt = Date().addingTimeInterval(86400) // 24 hours
        persist(data)
    }

    static func load() -> CacheData {
        guard let data = stored,
              data.expiresAt > Date() else {
            return CacheData.empty
        }
        return data
    }
}

The 24-hour TTL prevents stale bookmarks from lingering forever. When the iOS app becomes reachable, I sync the local cache and clear it. If sync fails, the cache persists until the next opportunity.

Bonus: ScenePhase-Driven Refresh

SwiftUI on watchOS gives you scenePhase to detect when your app wakes up:

@Environment(\.scenePhase) var scenePhase

.onChange(of: scenePhase) { phase in
    if phase == .active {
        fetchLatestArticles()
    }
}

This is crucial for news apps. When someone raises their wrist, they expect fresh content. But you don’t want to poll in the background and drain battery. ScenePhase gives you the best of both worlds.

Lessons Learned

Less is genuinely more. Every line of code on a watch has to earn its place. The constrained environment forced better decisions than I’d make on iOS.

WatchConnectivity beats AppGroups for most use cases. It’s simpler to set up, doesn’t require entitlement changes, and the on-demand pattern handles edge cases gracefully.

Test on real hardware. The simulator lies about performance and responsiveness. Things that feel instant in the simulator can feel sluggish on a real 38mm watch. I caught several issues only after strapping on an actual device.


If you’ve been putting off building a Watch companion for your iOS app, I hope this convinces you it’s more approachable than it seems. Start small, focus on one or two core features, and let the constraints guide you toward elegant solutions.

The Apple Watch might have the smallest screen, but sometimes the smallest canvas forces the most creative work.