
Watchos Code Review
- 118 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Pre-merge or pre-release review of Swift watchOS targets for complications, WatchConnectivity, background limits, battery/perf, and App Store readiness.
About
A watchOS code review skill from existential-birds/beagle that audits Apple Watch app code for platform-correct Swift patterns, complication design, WatchConnectivity with iOS companions, and release readiness before merge or ship.
- Swift and SwiftUI watchOS pattern and API correctness checks
- Complication, glance, and small-screen layout validation
- WatchConnectivity and iPhone companion sync review
- Battery, background task, and performance constraint auditing
- App Store and Apple Human Interface Guidelines compliance
Watchos Code Review by the numbers
- 118 all-time installs (skills.sh)
- Ranked #424 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill watchos-code-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 118 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Pre-merge or pre-release review of Swift watchOS targets for complications, WatchConnectivity, background limits, battery/perf, and App Store readiness.
Files
watchOS Code Review
Quick Reference
| Issue Type | Reference |
|---|---|
| App lifecycle, scenes, background modes, extended runtime | references/lifecycle.md |
| ClockKit, WidgetKit, timeline providers, Smart Stack | references/complications.md |
| WCSession, message passing, file transfer, reachability | references/connectivity.md |
| Memory limits, background refresh, battery optimization | references/performance.md |
Review Checklist
- [ ] SwiftUI App protocol used with
@WKApplicationDelegateAdaptorfor lifecycle events - [ ]
scenePhaseread from root view (not sheets/modals where it's always.active) - [ ]
WKExtendedRuntimeSessionstarted only while app is active (not from background) - [ ] Workout sessions recovered in
applicationDidFinishLaunching(not just delegate) - [ ] Background tasks scheduled at least 5 minutes apart; next scheduled before completing current
- [ ]
URLSessionDownloadTask(notDataTask) used for background network requests - [ ] WidgetKit used instead of ClockKit for watchOS 9+ complications
- [ ] Timeline includes future entries (not just current state); gaps avoided
- [ ]
TimelineEntryRelevanceimplemented for Smart Stack prioritization - [ ] WCSession delegate set before
activate(); singleton pattern used - [ ]
isReachablechecked beforesendMessage;transferUserInfofor critical data - [ ] Received files moved synchronously before delegate callback returns
When to Load References
- Reviewing app lifecycle, background modes, or extended sessions -> lifecycle.md
- Reviewing complications, widgets, or timeline providers -> complications.md
- Reviewing WCSession, iPhone-Watch communication -> connectivity.md
- Reviewing memory, battery, or performance issues -> performance.md
Output Format
Report issues using: [FILE:LINE] ISSUE_TITLE
Examples:
[WatchApp.swift:18] WKExtendedRuntimeSession started while app not active[ConnectivityManager.swift:42] WCSession.activate() before delegate assignment[ComplicationTimeline.swift:67] Timeline has no future entries
Hard gates (before reporting)
Complete in order for each finding you intend to report. Do not advance until the pass condition is satisfied.
1. Location artifact — The finding includes [FILE:LINE] (or a line range) copied from the current file contents; the path resolves in this repo. 2. Scope read — You read the full surrounding unit: the View body, WKApplicationDelegate / scene method, TimelineProvider implementation, WCSessionDelegate callback, or workout/background task handler that owns the behavior—not only a diff hunk. 3. watchOS or pairing claim (only if the finding depends on background modes, complication/timeline contracts, WCSession reachability or transfer semantics, workout or extended runtime rules, or device-specific limits) — You name one concrete artifact you inspected (for example Info.plist / target capabilities for background modes, the WK* / WCSession call order in source, entitlements, or a subsection you read in the matching doc from Quick Reference) or you downgrade the item to an open question in Review Questions. 4. Protocol — Pre-report steps in review-verification-protocol are satisfied for this item (no finding if they are not).
Use the issue format [FILE:LINE] ISSUE_TITLE for each reported finding. Hard gate 4 is the full pre-report checklist for this skill’s review type.
Review Questions
1. Is the app using modern SwiftUI lifecycle with delegate adaptor? 2. Are background tasks completing properly (calling setTaskCompletedWithSnapshot)? 3. Is UI update frequency reduced when isLuminanceReduced is true? 4. Are WatchConnectivity delegate callbacks dispatching to main thread? 5. Is TabView nested within another TabView? (Memory leak on watchOS)
watchOS Complications
Evolution
- ClockKit: Deprecated framework (watchOS 2-8)
- WidgetKit: Modern replacement (watchOS 9+)
- watchOS 10: Smart Stack with relevance-based prioritization
- watchOS 11:
RelevantContextAPI for context-aware widgets
Widget Families (WidgetKit)
| Family | Use Case | ClockKit Equivalent |
|---|---|---|
accessoryRectangular | Multiple lines, graphs | graphicRectangular |
accessoryCircular | Gauges, progress | graphicCircular variants |
accessoryInline | Single text line | utilitarianSmallFlat |
accessoryCorner | Icon + curved label (watchOS only) | utilitarianSmall |
Timeline Provider Types
Static Widget
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ())
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ())
}Configurable Widget (AppIntents)
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry
func snapshot(for configuration: ConfigIntent, in context: Context) async -> SimpleEntry
func timeline(for configuration: ConfigIntent, in context: Context) async -> Timeline<SimpleEntry>
func recommendations() -> [AppIntentRecommendation<ConfigIntent>]
}Smart Stack Relevance
struct SimpleEntry: TimelineEntry {
var date: Date
var event: Event?
var relevance: TimelineEntryRelevance? {
guard let event = event else {
return TimelineEntryRelevance(score: 0)
}
return TimelineEntryRelevance(
score: 10,
duration: event.endDate.timeIntervalSince(date)
)
}
}Critical Anti-Patterns
1. Exceeding Refresh Budget
// BAD: Called on every data change
func dataDidUpdate() {
WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
}
// GOOD: Throttle reloads, use timeline entries
func getTimeline(...) {
var entries: [Entry] = []
for hourOffset in 0..<24 {
let date = Calendar.current.date(byAdding: .hour, value: hourOffset, to: Date())!
entries.append(Entry(date: date, data: predictedData(for: date)))
}
completion(Timeline(entries: entries, policy: .atEnd))
}Budget: ~40-70 refreshes/day (~every 15-60 minutes)
2. Gaps in Timeline
// BAD: Only entries for events
func getTimeline(...) {
for event in events {
entries.append(Entry(date: event.startDate, event: event))
}
}
// GOOD: Entries for state changes
func getTimeline(...) {
entries.append(Entry(date: Date(), event: currentEvent))
for event in upcomingEvents {
entries.append(Entry(date: event.startDate, event: event))
entries.append(Entry(date: event.endDate, event: nil)) // End state
}
}3. Expensive Operations in Placeholder
// BAD: Blocks UI
func placeholder(in context: Context) -> Entry {
let data = fetchLatestData() // Network call!
return Entry(date: Date(), data: data)
}
// GOOD: Return static data immediately
func placeholder(in context: Context) -> Entry {
return Entry(date: Date(), data: .placeholder)
}4. AsyncImage in Widget
// BAD: Won't work
var body: some View {
AsyncImage(url: imageURL) // Widgets can't do async in view
}
// GOOD: Fetch in timeline provider
func getTimeline(...) {
let imageData = try? Data(contentsOf: imageURL)
let entry = Entry(date: Date(), imageData: imageData)
completion(Timeline(entries: [entry], policy: .atEnd))
}5. Not Implementing Migration
// BAD: User complications become blank
class ComplicationController: NSObject, CLKComplicationDataSource {
// Missing: var widgetMigrator: CLKComplicationWidgetMigrator
}
// GOOD: Implement migration
extension ComplicationController: CLKComplicationWidgetMigrator {
func widgetConfiguration(
from descriptor: CLKComplicationDescriptor
) async -> CLKComplicationWidgetMigrationConfiguration? {
return CLKComplicationStaticWidgetMigrationConfiguration(
kind: "MyWidget",
extensionBundleIdentifier: "com.myapp.widget"
)
}
}Key Modifiers
| Modifier | Purpose |
|---|---|
.widgetAccentable() | Mark for accent coloring |
.widgetLabel { } | Curved text for corner/circular |
.containerBackground(for: .widget) | Smart Stack background |
.privacySensitive() | Redact in Always-On |
AccessoryWidgetBackground() | Consistent backdrop |
Always-On Display
var body: some View {
VStack {
Image(systemName: "heart.fill")
.widgetAccentable()
if isLuminanceReduced {
Text("\(value)")
.redacted(reason: .placeholder) // Hide sensitive
} else {
Text("\(value) BPM")
.privacySensitive()
}
}
}Review Questions
1. Is WidgetKit used instead of ClockKit (watchOS 9+)? 2. Does placeholder() return immediately without async work? 3. Does the timeline include future entries (not just current)? 4. Is TimelineEntryRelevance implemented for Smart Stack? 5. Is .privacySensitive() applied to sensitive content? 6. Is @Environment(\.isLuminanceReduced) checked for Always-On? 7. Are images pre-fetched (not using AsyncImage)? 8. Is ClockKit migration implemented if updating from older app?
WatchConnectivity
Communication Methods
| Method | Use Case | Guaranteed | Queuing |
|---|---|---|---|
sendMessage(_:) | Real-time, immediate | No | None |
transferUserInfo(_:) | Critical data | Yes | FIFO |
updateApplicationContext(_:) | State sync, latest only | Yes (latest) | Overwrites |
transferFile(_:) | Large files | Yes | FIFO |
transferCurrentComplicationUserInfo(_:) | Complication data | Yes | Budget limited |
Session Setup
final class WatchConnectivityService: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityService()
override private init() {
super.init()
#if !os(watchOS)
guard WCSession.isSupported() else { return }
#endif
WCSession.default.delegate = self
WCSession.default.activate()
}
}Required Delegate Methods
iOS (all three required):
session(_:activationDidCompleteWith:error:)sessionDidBecomeInactive(_:)sessionDidDeactivate(_:)
watchOS (one required):
session(_:activationDidCompleteWith:error:)
Pre-Send Validation
private func canSendToPeer() -> Bool {
guard WCSession.default.activationState == .activated else { return false }
#if os(watchOS)
guard WCSession.default.isCompanionAppInstalled else { return false }
#else
guard WCSession.default.isWatchAppInstalled else { return false }
#endif
return true
}
// For sendMessage only
if WCSession.default.isReachable {
WCSession.default.sendMessage(message, replyHandler: nil, errorHandler: nil)
}Critical Anti-Patterns
1. Setup in View Controller
// BAD: Won't be called during background launches
class MyViewController: UIViewController {
override func viewDidLoad() {
WCSession.default.delegate = self
WCSession.default.activate()
}
}
// GOOD: Singleton in early lifecycle
// In AppDelegate
func application(...) -> Bool {
_ = WatchConnectivityService.shared
return true
}2. Using sendMessage for Critical Data
// BAD: Lost when counterpart not reachable
func sendToWatch(_ data: [String: Any]) {
WCSession.default.sendMessage(data, replyHandler: nil, errorHandler: nil)
}
// GOOD: Use appropriate method based on criticality
func sendToWatch(_ data: [String: Any], critical: Bool) {
guard canSendToPeer() else { return }
if critical {
WCSession.default.transferUserInfo(data)
} else if WCSession.default.isReachable {
WCSession.default.sendMessage(data, replyHandler: nil, errorHandler: nil)
}
}3. UI Updates on Background Thread
// BAD: Delegate runs on background thread
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
self.label.text = message["text"] as? String // Crash!
}
// GOOD: Dispatch to main
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
DispatchQueue.main.async {
self.label.text = message["text"] as? String
}
}4. Async File Handling
// BAD: File deleted before async completes
func session(_ session: WCSession, didReceive file: WCSessionFile) {
DispatchQueue.global().async {
try? FileManager.default.moveItem(at: file.fileURL, to: destination)
}
}
// GOOD: Synchronous move first
func session(_ session: WCSession, didReceive file: WCSessionFile) {
do {
try FileManager.default.moveItem(at: file.fileURL, to: destination)
DispatchQueue.main.async {
self.processFile(at: destination)
}
} catch {
print("Failed: \(error)")
}
}5. Not Reactivating After Deactivation
// BAD: Session unusable after watch swap
func sessionDidDeactivate(_ session: WCSession) {
// Nothing
}
// GOOD: Reactivate for watch swaps
func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate()
}6. Reply Handler When Not Expecting Reply
// BAD: OS generates errors
WCSession.default.sendMessage(data, replyHandler: { _ in }, errorHandler: nil)
// GOOD: nil when no reply expected
WCSession.default.sendMessage(data, replyHandler: nil, errorHandler: { error in
print("Error: \(error)")
})Data Type Requirements
Only Plist-encodable types allowed:
- String, Int, Double, Bool
- Data
- Array, Dictionary (of above types)
// BAD: Custom types
WCSession.default.sendMessage(["user": myUser], ...)
// GOOD: Encode first
let data = try JSONEncoder().encode(myUser)
WCSession.default.sendMessage(["userData": data], ...)Review Questions
1. Is WCSession.isSupported() checked on iOS before setup? 2. Is delegate set before activate() (use singleton)? 3. Is activationState == .activated checked before sending? 4. Is isReachable checked for sendMessage calls? 5. Is transferUserInfo used for data that must be delivered? 6. Are delegate callbacks dispatching UI updates to main thread? 7. Are received files moved synchronously before delegate returns? 8. Is sessionDidDeactivate reactivating the session on iOS? 9. Are only Plist-encodable types being sent?
WatchKit App Lifecycle
Lifecycle Architecture
watchOS uses two lifecycle models:
SwiftUI App Protocol (Modern)
@main
struct MyWatchApp: App {
@WKApplicationDelegateAdaptor var appDelegate: MyAppDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}WKApplicationDelegate
Use for lifecycle events not covered by SwiftUI's scenePhase. Note: WKExtensionDelegate was renamed to WKApplicationDelegate in Xcode 14.
Scene Phase States
| State | Description |
|---|---|
.active | App in foreground, user can interact |
.inactive | Visible but no interaction (wrist lowered, screen on) |
.background | Not visible, may be terminated |
Note: On watchOS, .inactive does NOT mean the app isn't running.
Background Execution Modes
| Mode | Use Case | Constraints |
|---|---|---|
BGAppRefreshTask | Data updates | 4 per hour; 4s CPU, 15s total |
HKWorkoutSession | Workout tracking | Continuous; use for workouts only |
WKExtendedRuntimeSession | Self-care, mindfulness | Start while active only |
| Background URLSession | Downloads | Requires complication or dock |
WKExtendedRuntimeSession Types
| Type | Duration | Notes |
|---|---|---|
| Self Care | 10 minutes | |
| Mindfulness | 1 hour | |
| Physical Therapy | 1 hour | Allows background multitasking |
| Health Monitoring | Variable | Requires entitlement |
| Alarm | 30 minutes | Use startAtDate() to schedule |
Critical Anti-Patterns
1. Heavy Work in Lifecycle Methods
// BAD: Slows resume time
func applicationDidBecomeActive() {
loadAllDataFromDisk()
syncWithServer()
}
// GOOD: Defer to background
func applicationDidBecomeActive() {
Task.detached(priority: .background) {
await self.prefetchData()
}
}2. Reading scenePhase in Sheets
// BAD: Always returns .active in sheets
struct SettingsSheet: View {
@Environment(\.scenePhase) var scenePhase // Broken!
}
// GOOD: Pass from root view
struct ContentView: View {
@Environment(\.scenePhase) var scenePhase
var body: some View {
Button("Settings") { showSettings = true }
.sheet(isPresented: $showSettings) {
SettingsSheet(scenePhase: scenePhase)
}
}
}3. Starting Extended Sessions from Background
// BAD: Cannot start from background
func applicationDidEnterBackground() {
let session = WKExtendedRuntimeSession()
session.start() // Error!
}
// GOOD: Start while active
func startMindfulnessSession() {
guard WKApplication.shared().applicationState == .active else { return }
extendedSession = WKExtendedRuntimeSession()
extendedSession?.start()
}4. Not Recovering Workout Sessions
// BAD: handleActiveWorkoutRecovery NOT called on reboot
class AppDelegate: NSObject, WKApplicationDelegate {
func handleActiveWorkoutRecovery() {
recoverWorkout()
}
}
// GOOD: Check in applicationDidFinishLaunching
func applicationDidFinishLaunching() {
Task {
do {
let (session, builder) = try await HKHealthStore().recoverActiveWorkoutSession()
workoutManager.resume(session: session, builder: builder)
} catch {
// No session to recover
}
}
}5. Network Calls During Background Transition
// BAD: Not enough time
func applicationWillResignActive() {
URLSession.shared.dataTask(with: url) { ... } // Won't complete
}
// GOOD: Use expiring activity
func applicationWillResignActive() {
ProcessInfo.processInfo.performExpiringActivity(withReason: "Sync") { expired in
guard !expired else { return }
self.quickSync()
}
}Background App Refresh
Correct Pattern
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
if let refreshTask = task as? WKApplicationRefreshBackgroundTask {
// 1. Schedule next FIRST
scheduleNextRefresh()
// 2. Use download task (not data task)
let config = URLSessionConfiguration.background(withIdentifier: "com.app.refresh")
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
session.downloadTask(with: url).resume()
// 3. Complete this task
refreshTask.setTaskCompletedWithSnapshot(false)
}
}
}
func scheduleNextRefresh() {
// At least 5 minutes in future
let preferredDate = Date().addingTimeInterval(5 * 60)
WKApplication.shared().scheduleBackgroundRefresh(
withPreferredDate: preferredDate,
userInfo: nil
) { _ in }
}Review Questions
1. Is SwiftUI App protocol used with @WKApplicationDelegateAdaptor for lifecycle events? 2. Is scenePhase read from root view (not sheets/modals)? 3. Are extended runtime sessions started only while app is active? 4. Is HKHealthStore().recoverActiveWorkoutSession() called in applicationDidFinishLaunching? 5. Are background tasks scheduled at least 5 minutes apart? 6. Is URLSessionDownloadTask (not DataTask) used for background network? 7. Is next refresh scheduled BEFORE completing current task?
watchOS Performance
Constraints
Memory
| Constraint | Limit |
|---|---|
| Device RAM | ~1 GB (Series 9/10/Ultra 2) |
| App bundle | ~50 MB |
| Widget/Complication images | ~30 MB |
| Background task memory | Limited |
CPU and Battery
| Constraint | Limit |
|---|---|
| CPU usage threshold | <80% sustained |
| Background task duration | ~3 min when backgrounding; ~30s when resumed |
| Background refresh | 4 per hour with complication; 15+ min apart |
| Extended runtime | Battery-intensive; end promptly |
Network
| Consideration | Details |
|---|---|
| Connection | URLSession abstracts Bluetooth/Wi-Fi/cellular |
| WebSocket/Stream | Not supported |
| Background minimum interval | 10+ minutes recommended |
Critical Anti-Patterns
1. Nested TabViews (Memory Leak)
// BAD: Causes memory leaks
NavigationStack {
TabView {
TabView { // DON'T NEST!
ContentView()
}
}
}
// GOOD: Single level
NavigationStack {
TabView {
ContentView()
}
}2. Not Completing Background Tasks
// BAD: Missing completion handler
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
if let refreshTask = task as? WKApplicationRefreshBackgroundTask {
doWork()
// MISSING: setTaskCompletedWithSnapshot!
}
}
}
// GOOD: Always complete with defer
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
if let refreshTask = task as? WKApplicationRefreshBackgroundTask {
defer { refreshTask.setTaskCompletedWithSnapshot(false) }
doWork()
}
}
}3. Protected File Access in Background
// BAD: Fails when screen locked
func backgroundHandler() {
let data = try? Data(contentsOf: protectedFileURL) // Fails!
}
// GOOD: Use no file protection for background data
try data.write(to: url, options: .noFileProtection)4. WKInterface Property Updates
// BAD: Each property = ~200ms message
func updateUI() {
label.setText(newText) // Update 1
label.setTextColor(.red) // Update 2
image.setImage(newImage) // Update 3
}
// GOOD: Only set when values change
func updateUI() {
if textChanged {
label.setText(newText)
}
if colorChanged {
label.setTextColor(.red)
}
}5. Constant UI Updates During Workout
// BAD: Updates even when dimmed
struct WorkoutView: View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text("\(heartRate)")
.onReceive(timer) { _ in updateUI() }
}
}
// GOOD: Adaptive update rate
struct WorkoutView: View {
@Environment(\.isLuminanceReduced) var isLuminanceReduced
var body: some View {
TimelineView(.periodic(from: .now, by: updateInterval)) { _ in
Text("\(heartRate)")
}
}
var updateInterval: TimeInterval {
isLuminanceReduced ? 10.0 : 1.0 // Slower when dimmed
}
}6. Large WKInterfaceTable
// BAD: All cells load upfront (no reuse)
func loadTable(items: [Item]) {
table.setNumberOfRows(items.count, withRowType: "Row") // 100+ rows = bad
}
// GOOD: Keep under 20 rows, use incremental updates
func loadTable(items: [Item]) {
let limitedItems = Array(items.prefix(20))
table.setNumberOfRows(limitedItems.count, withRowType: "Row")
}
func addRows(at indexes: IndexSet) {
table.insertRows(at: indexes, withRowType: "Row") // Incremental
}7. Loading All Data
// BAD: Load everything
func loadRecords() async -> [Record] {
return await database.fetchAll()
}
// GOOD: Load what's displayed
func loadRecords(limit: Int = 10) async -> [Record] {
return await database.fetch(limit: limit)
}Battery Optimization
Extended Runtime Sessions
// Always end when activity completes
class MindfulnessManager {
var session: WKExtendedRuntimeSession?
func startSession(duration: TimeInterval) {
session = WKExtendedRuntimeSession()
session?.start()
DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
self?.session?.invalidate()
self?.session = nil
}
}
}Image Optimization
// Downsample to display size
func displayImage(_ image: UIImage, targetSize: CGSize) {
let renderer = UIGraphicsImageRenderer(size: targetSize)
let downsampledImage = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
imageView.setImage(downsampledImage)
}HealthKit Queries
// Store and stop long-running queries
class HealthManager {
var observerQuery: HKObserverQuery?
deinit {
if let query = observerQuery {
healthStore.stop(query)
}
}
}Review Questions
1. Is TabView nested within another TabView? (Memory leak) 2. Are all WKRefreshBackgroundTask completion handlers called? 3. Are files using .noFileProtection if accessed in background? 4. Is UI update frequency reduced when isLuminanceReduced is true? 5. Is WKExtendedRuntimeSession invalidated when activity completes? 6. Are WKInterface properties only set when values change? 7. Are WKInterfaceTables kept under 20 rows? 8. Are images downsampled to display size? 9. Are long-running queries stored and stopped in deinit?