/images/posts/signal-ios.png
Signal is one of the most privacy-focused messaging apps in the world, and its
open-source iOS codebase is a goldmine of innovative patterns and solutions to
complex problems. After analyzing the parts of codebase, I've identified 10
unique patterns that every iOS developer can learn from.
1. Adaptive Display Link Throttling#
The Problem#
Signal's conversation view needs to update the UI when database changes occur
(new messages, read receipts, reactions, etc.). The naive approach is to update
immediately on every change, but this causes severe UI jank when many changes
happen rapidly (like during initial sync or when receiving a burst of
messages).
A fixed timer approach (e.g., update every 100ms) doesn't work either because:
On powerful devices, you're wasting potential responsiveness On older devices, you might still be updating too frequently and causing jank
Signal's Solution#
Signal uses CADisplayLink to batch database change notifications and dynamically adjusts the update frequency based on real-time system load .
Swift Copy
1 // Measure system load by monitoring display link performance 2 private func updateDisplayLinkFrequency (displayLinkDuration: TimeInterval ) { 3 // If display link is firing slower than expected, system is under load 4 let actualFrequency = 1.0 / displayLinkDuration 5 6 // Use linear interpolation to determine target update interval 7 let displayLinkAlpha = recentDisplayLinkFrequency. inverseLerp ( 8 lightDisplayLinkFrequency, // 60 fps (system responsive) 9 heavyDisplayLinkFrequency, // 20 fps (system struggling) 10 shouldClamp: true 11 ) 12 13 // Slow down updates when system is struggling 14 let targetInterval = displayLinkAlpha. lerp ( 15 fastUpdateInterval, // 0.05s when responsive 16 slowUpdateInterval // 0.5s when struggling 17 ) 18 19 self . targetUpdateInterval = targetInterval 20 }
How It's Used in Signal#
When you receive multiple messages in a conversation:
Database changes are batched and queued Display link fires at screen refresh rate (60 fps on most devices) If system load increases (display link slows to 20-30 fps), Signal automatically reduces UI update frequency This prevents the dreaded "scroll stuttering" when messages are coming in
Why This Is Better#
Traditional approach:
Swift Copy
1 // Updates immediately - causes jank with rapid changes 2 NotificationCenter . default . addObserver { _ in 3 self . tableView . reloadData () 4 } 5 6 // Fixed timer - not adaptive 7 Timer . scheduledTimer (withTimeInterval: 0.1 , repeats: true ) { _ in 8 self . updateUI () 9 }
Signal's approach:
Swift Copy
1 // Adaptive throttling based on real device performance 2 // Fast devices: ~50ms updates (responsive) 3 // Slow devices under load: ~500ms updates (prevents jank) 4 // Automatically adjusts based on actual display link fire rate
What You Can Learn#
Use CADisplayLink as a load indicator : If it fires slower than 60fps, your system is under pressureLinear interpolation (lerp) is powerful : Smooth transitions between performance statesAdaptive behavior beats fixed timers : Let the system tell you when it's strugglingBatch UI updates to frame rate : Never update faster than the screen can display
How to Apply This in Your App#
Swift Copy
1 class AdaptiveUpdateManager { 2 private var displayLink: CADisplayLink ? 3 private var lastUpdateTime: CFTimeInterval = 0 4 private var targetUpdateInterval: TimeInterval = 0.05 5 6 func startMonitoring () { 7 displayLink = CADisplayLink (target: self , selector: # selector (displayLinkFired)) 8 displayLink?. add (to: . main , forMode: . common ) 9 } 10 11 @objc private func displayLinkFired (_ link: CADisplayLink ) { 12 let duration = link. timestamp - lastUpdateTime 13 lastUpdateTime = link. timestamp 14 15 // Measure how long between display link fires 16 // If > 16.67ms (60fps), system is under load 17 let actualFPS = 1.0 / duration 18 19 // Adjust update frequency based on load 20 if actualFPS < 40 { 21 targetUpdateInterval = 0.5 // Slow down 22 } else if actualFPS > 55 { 23 targetUpdateInterval = 0.05 // Speed up 24 } 25 26 performPendingUpdatesIfNeeded () 27 } 28 }
Show all 28 lines
Use cases:
Real-time chat apps with message bursts Social media feeds with live updates Live data dashboards Any app with frequent model updates
2. Multi-Level Window Management#
The Problem#
Signal has multiple overlapping UI concerns that need precise z-ordering:
Normal app UI (messages, settings) Active call interface (must stay on top during navigation) Return-to-call banner (floating pip when you leave a call) Screen blocking (privacy screen when app is backgrounded) Captcha challenges (must be above everything)
Using a single UIWindow and layering UIViewController presentations creates problems:
Modal presentations can dismiss unexpectedly Navigation interferes with overlays Rotation bugs with presented view controllers Can't have precise control over what's on top
Signal's Solution#
Signal uses multiple UIWindow instances at custom z-levels with a sophisticated WindowManager to coordinate them.
Swift Copy
1 extension UIWindow . Level { 2 // Below everything - used for background blur 3 static let _background = UIWindow . Level (rawValue: - 1 ) 4 5 // Normal app content 6 static let _normal = UIWindow . Level . normal 7 8 // Return-to-call banner (above normal UI) 9 static let _returnToCall = UIWindow . Level (rawValue: UIWindow . Level . normal . rawValue + 2 ) 10 11 // Active call view (above banner) 12 static let _callView = UIWindow . Level (rawValue: UIWindow . Level . normal . rawValue + 3 ) 13 14 // Screen blocking for privacy (above calls) 15 static let _screenBlocking = UIWindow . Level (rawValue: UIWindow . Level . alert . rawValue + 5 ) 16 } 17 18 class WindowManager { 19 private lazy var callViewWindow: UIWindow = { 20 let window = UIWindow () 21 window. windowLevel = . _callView 22 window. isHidden = false 23 window. backgroundColor = . clear 24 return window 25 }() 26 27 private lazy var returnToCallWindow: UIWindow = { 28 let window = UIWindow () 29 window. windowLevel = . _returnToCall 30 window. isHidden = false 31 window. backgroundColor = . clear 32 return window 33 }() 34 35 func startCall (call: SignalCall ) { 36 let callVC = CallViewController (call: call) 37 callViewWindow. rootViewController = callVC 38 ensureWindowState () // Bring to appropriate level 39 } 40 41 // Uses window level changes instead of hidden property 42 // to avoid UIKit layout bugs 43 private func ensureWindowState () { 44 if hasActiveCall { 45 callViewWindow. windowLevel = . _callView 46 } else { 47 callViewWindow. windowLevel = . _background // Hide by lowering 48 } 49 } 50 }
Show all 50 lines
How It's Used in Signal#
Scenario 1: Starting a Call
User initiates a call from a conversation WindowManager creates call UI in callViewWindow at level ._callViewUser can navigate anywhere in the app - call UI stays on top Tapping "return to call" switches to returnToCallWindow with a floating pip
Scenario 2: App Backgrounding
User presses home button during sensitive conversation WindowManager immediately raises screenBlockingWindow to ._screenBlocking levelScreen contents are hidden before app snapshot is taken Privacy preserved in app switcher
Scenario 3: Captcha Challenge
Server requires captcha during registration Captcha UI presented in dedicated window at appropriate level Works regardless of current navigation state Can't be accidentally dismissed by user navigation
Why This Is Better#
Traditional approach:
Swift Copy
1 // Presenting modally - can be dismissed, interferes with navigation 2 present (callViewController, animated: true ) 3 4 // Adding subview to window - wrong z-order, rotation issues 5 UIApplication . shared . keyWindow ?. addSubview (callView)
Signal's approach:
Swift Copy
1 // Dedicated windows with precise z-ordering 2 // Each window has its own view controller hierarchy 3 // No interference between different UI concerns 4 callViewWindow. windowLevel = . _callView 5 screenBlockingWindow. windowLevel = . _screenBlocking
Advanced Technique: Private API Workarounds#
Signal even uses encoded selectors to work around iOS bugs:
Swift Copy
1 // iOS has a rotation bug with certain window configurations 2 // Access private API safely using encoded selector names 3 private func applyRotationWorkaround () { 4 let selectorData = Data (base64Encoded: "X3VwZGF0ZVRvSW50ZXJmYWNlT3JpZW50YXRpb246" )! 5 let selectorString = String (data: selectorData, encoding: . utf8 )! 6 let selector = NSSelectorFromString (selectorString) 7 8 if window. responds (to: selector) { 9 window. perform (selector, with: nil ) 10 } 11 }
This decodes to _updateToInterfaceOrientation: - a private method to fix rotation bugs.
What You Can Learn#
Multiple windows solve layering problems : Better than view controller presentation for persistent overlaysWindow levels give precise z-ordering : No fighting with UIKit's presentation logicLazy window initialization : Only create windows when neededUse level changes instead of hidden property : Avoids UIKit layout recalculation bugsPrivate API access can be done safely : Use encoding to avoid App Store rejection
How to Apply This in Your App#
Swift Copy
1 class OverlayWindowManager { 2 // Main app window (set by system) 3 // Level: UIWindow.Level.normal 4 5 // Custom overlay window 6 private lazy var overlayWindow: UIWindow = { 7 let window = UIWindow (frame: UIScreen . main . bounds ) 8 window. windowLevel = UIWindow . Level (rawValue: UIWindow . Level . normal . rawValue + 1 ) 9 window. backgroundColor = . clear 10 window. isHidden = false 11 return window 12 }() 13 14 func showOverlay (_ viewController: UIViewController ) { 15 overlayWindow. rootViewController = viewController 16 overlayWindow. windowLevel = UIWindow . Level (rawValue: UIWindow . Level . normal . rawValue + 1 ) 17 overlayWindow. makeKey () // Receives touch events 18 } 19 20 func hideOverlay () { 21 overlayWindow. windowLevel = UIWindow . Level (rawValue: - 1 ) 22 // Don't set isHidden - can cause layout bugs 23 } 24 }
Show all 24 lines
Use cases:
VoIP apps with persistent call UI Music/podcast players with mini player Privacy screens for sensitive content Picture-in-picture overlays Loading/blocking overlays that must not be dismissible
3. Dual-Mode Debouncing#
The Problem#
Debouncing is a common pattern, but different UI scenarios need different debouncing strategies:
Scenario A : User typing in a search field
You want to wait until they stop typing before searching Traditional "last only" debounce - wait 300ms of silence
Scenario B : User scrolling through a conversation
You want immediate visual feedback (no delay) But you want to throttle expensive operations (database queries) You need "first immediately, then throttle subsequent"
Most debouncing libraries only support one mode.
Signal's Solution#
Signal implements two distinct debouncing modes in a single, well-designed class.
Swift Copy
1 public class DebouncedEvent { 2 public enum Mode { 3 /// Waits before firing - good for batching operations 4 /// Example: Search API calls while user types 5 case lastOnly 6 7 /// Fires immediately on first request, then throttles subsequent 8 /// Example: UI updates during scrolling (responsive but not excessive) 9 case firstLast 10 } 11 12 private let mode: Mode 13 private let maxFrequencySeconds: TimeInterval 14 private let queue: DispatchQueue 15 16 private var timer: Timer ? 17 private var pendingRequest: (() -> Void )? 18 private var lastFireDate: Date ? 19 20 public init ( 21 mode: Mode , 22 maxFrequencySeconds: TimeInterval , 23 queue: DispatchQueue = . main 24 ) { 25 self . mode = mode 26 self . maxFrequencySeconds = maxFrequencySeconds 27 self . queue = queue 28 } 29 30 public func request (_ callback: @escaping () -> Void ) { 31 pendingRequest = callback 32 33 switch mode { 34 case . lastOnly : 35 // Cancel existing timer, restart countdown 36 timer?. invalidate () 37 timer = Timer . scheduledTimer ( 38 withTimeInterval: maxFrequencySeconds, 39 repeats: false 40 ) { [weak self ] _ in 41 self ?. fireIfNeeded () 42 } 43 44 case . firstLast : 45 // Fire immediately if enough time has passed 46 let now = Date () 47 let shouldFireImmediately: Bool 48 49 if let lastFireDate = lastFireDate { 50 let timeSinceLast = now. timeIntervalSince (lastFireDate) 51 shouldFireImmediately = timeSinceLast >= maxFrequencySeconds 52 } else { 53 shouldFireImmediately = true // First request 54 } 55 56 if shouldFireImmediately { 57 fireIfNeeded () 58 } else { 59 // Schedule for later 60 timer?. invalidate () 61 timer = Timer . scheduledTimer ( 62 withTimeInterval: maxFrequencySeconds, 63 repeats: false 64 ) { [weak self ] _ in 65 self ?. fireIfNeeded () 66 } 67 } 68 } 69 } 70 71 private func fireIfNeeded () { 72 guard let pendingRequest = pendingRequest else { return } 73 lastFireDate = Date () 74 self . pendingRequest = nil 75 queue. async { 76 pendingRequest () 77 } 78 } 79 }
Show all 79 lines
How It's Used in Signal#
Use Case 1: Content Inset Updates (FirstLast mode)
Swift Copy
1 class ConversationViewController : UIViewController { 2 // User is scrolling - we want immediate first update, 3 // then throttle subsequent updates to avoid jank 4 private lazy var contentInsetDebouncer = DebouncedEvent ( 5 mode: . firstLast , // ← Responsive! 6 maxFrequencySeconds: 0.05 // Max 20 updates/sec 7 ) 8 9 func scrollViewDidScroll (_ scrollView: UIScrollView ) { 10 contentInsetDebouncer. request { [weak self ] in 11 self ?. updateContentInsets () // Expensive operation 12 } 13 } 14 }
Result : First scroll update happens immediately (feels responsive), then throttled to prevent UI jank.
Use Case 2: Database Change Notifications (LastOnly mode)
Swift Copy
1 class DatabaseChangeObserver { 2 // Cross-process database changes - we want to batch them 3 private lazy var crossProcessDebouncer = DebouncedEvent ( 4 mode: . lastOnly , // ← Batching! 5 maxFrequencySeconds: 0.2 // Wait 200ms of silence 6 ) 7 8 func handleDarwinNotification () { 9 // Another process (NSE) changed database 10 crossProcessDebouncer. request { [weak self ] in 11 self ?. reloadFromDatabase () // Expensive 12 } 13 } 14 }
Result : If 10 changes happen in 150ms, only the last one triggers reload.
Why This Is Better#
Traditional debouncing (one mode):
Swift Copy
1 // Only supports "last only" mode 2 class SimpleDebouncer { 3 func debounce (_ action: @escaping () -> Void ) { 4 timer?. invalidate () 5 timer = Timer . scheduledTimer (...) { action () } 6 } 7 } 8 9 // Using it for scrolling feels laggy (waits before first update) 10 debouncer. debounce { updateUI () } // 300ms delay before first update
Signal's approach:
Swift Copy
1 // Choose the right mode for the use case 2 let searchDebouncer = DebouncedEvent (mode: . lastOnly , maxFrequencySeconds: 0.3 ) 3 let scrollDebouncer = DebouncedEvent (mode: . firstLast , maxFrequencySeconds: 0.05 ) 4 5 // Searching: waits for user to stop typing 6 searchDebouncer. request { performSearch () } 7 8 // Scrolling: immediate first update, then throttled 9 scrollDebouncer. request { updateUI () } // Fires immediately!
What You Can Learn#
Different UX scenarios need different debouncing : One size doesn't fit all"FirstLast" mode provides responsive UX : No perceived delay on first action"LastOnly" mode is better for batching : Reduces unnecessary workTrack last fire date for throttling : Simple but effectiveProvide queue parameter : Allows background queue execution
When to Use Each Mode#
lastOnly mode - Wait for user to finish action before executing
Search-as-you-type Form validation Auto-save drafts
firstLast mode - Immediate feedback but throttle subsequent actions
Scroll updates Gesture tracking Live resize
How to Apply This in Your App#
Swift Copy
1 class SmartDebouncer { 2 enum Mode { 3 case waitForSilence // Traditional debounce 4 case immediateAndThrottle // Responsive throttle 5 } 6 7 private let mode: Mode 8 private let delay: TimeInterval 9 private var timer: Timer ? 10 private var lastExecutionTime: Date ? 11 12 func trigger (_ action: @escaping () -> Void ) { 13 switch mode { 14 case . waitForSilence : 15 // Cancel and restart timer 16 timer?. invalidate () 17 timer = Timer . scheduledTimer (withTimeInterval: delay, repeats: false ) { _ in 18 action () 19 } 20 21 case . immediateAndThrottle : 22 let now = Date () 23 let shouldExecuteNow = lastExecutionTime == nil || 24 now. timeIntervalSince (lastExecutionTime!) >= delay 25 26 if shouldExecuteNow { 27 lastExecutionTime = now 28 action () 29 } else { 30 // Schedule for later 31 timer?. invalidate () 32 timer = Timer . scheduledTimer (withTimeInterval: delay, repeats: false ) { [weak self ] _ in 33 self ?. lastExecutionTime = Date () 34 action () 35 } 36 } 37 } 38 } 39 } 40 41 // Usage 42 let searchDebouncer = SmartDebouncer (mode: . waitForSilence , delay: 0.3 ) 43 let scrollDebouncer = SmartDebouncer (mode: . immediateAndThrottle , delay: 0.05 )
Show all 43 lines
4. Sendable-Compatible Atomics#
The Problem#
With Swift Concurrency (async/await), the compiler enforces Sendable conformance to prevent data races. But many existing thread-safe types don't conform to Sendable:
Swift Copy
1 class Counter { 2 private let lock = NSLock () 3 private var _value: Int = 0 4 5 var value: Int { 6 lock. lock () 7 defer { lock. unlock () } 8 return _value 9 } 10 } 11 12 // Error: 'Counter' doesn't conform to 'Sendable' 13 Task { 14 let counter = Counter () 15 await someAsyncFunction (counter) 16 }
You could mark it @unchecked Sendable, but that disables compiler safety checks.
Signal's Solution#
Signal provides a complete suite of atomic types that are properly Sendable and provide clean APIs including property wrappers.
Swift Copy
1 @propertyWrapper 2 public struct Atomic < Value >: @unchecked Sendable { 3 private let lock = UnfairLock () 4 private var _value: Value 5 6 public init (wrappedValue: Value ) { 7 self . _value = wrappedValue 8 } 9 10 public var wrappedValue: Value { 11 get { lock. withLock { _value } } 12 set { lock. withLock { _value = newValue } } 13 } 14 15 // Read and modify atomically 16 public func update< T >(_ block: ( inout Value ) -> T ) -> T { 17 lock. withLock { 18 block (&_value) 19 } 20 } 21 22 // State machine transitions 23 public func transition (from: Value , to: Value ) throws where Value : Equatable { 24 try lock. withLock { 25 guard _value == from else { 26 throw AtomicError . transitionFailed 27 } 28 _value = to 29 } 30 } 31 } 32 33 // Specialized atomic types 34 public final class AtomicBool : @unchecked Sendable { 35 private let lock = UnfairLock () 36 private var _value: Bool 37 38 public init (_ value: Bool ) { 39 self . _value = value 40 } 41 42 // Try to set flag atomically 43 public func tryToSetFlag () -> Bool { 44 lock. withLock { 45 guard !_value else { return false } 46 _value = true 47 return true 48 } 49 } 50 51 public func tryToClearFlag () -> Bool { 52 lock. withLock { 53 guard _value else { return false } 54 _value = false 55 return true 56 } 57 } 58 } 59 60 // Atomic collections 61 public final class AtomicArray < Element >: @unchecked Sendable { 62 private let lock = UnfairLock () 63 private var _value: [ Element ] 64 65 public func append (_ element: Element ) { 66 lock. withLock { 67 _value. append (element) 68 } 69 } 70 71 public func removeAll () -> [ Element ] { 72 lock. withLock { 73 let old = _value 74 _value = [] 75 return old 76 } 77 } 78 } 79 80 public final class AtomicDictionary < Key : Hashable , Value >: @unchecked Sendable { 81 private let lock = UnfairLock () 82 private var _value: [ Key : Value ] 83 84 public subscript (key: Key ) -> Value ? { 85 get { lock. withLock { _value[key] } } 86 set { lock. withLock { _value[key] = newValue } } 87 } 88 }
Show all 88 lines
How It's Used in Signal#
Use Case 1: State Machine with Transitions
Swift Copy
1 public final class CancellableContinuation < T >: Sendable { 2 private enum State { 3 case initial 4 case waiting ( CheckedContinuation < T , Error >) 5 case completed ( Result < T , Error >) 6 case consumed 7 } 8 9 @ Atomic private var state: State = . initial 10 11 public func waitForResult () async throws -> T { 12 // Use atomic transition to move from .initial → .waiting 13 try await withCheckedThrowingContinuation { continuation in 14 do { 15 try $state. transition (from: . initial , to: . waiting (continuation)) 16 } catch { 17 // Already completed - resume immediately 18 if case . completed ( let result) = state { 19 continuation. resume (with: result) 20 } 21 } 22 } 23 } 24 25 public func resume (returning value: T ) { 26 $state. update { state in 27 switch state { 28 case . waiting ( let continuation): 29 continuation. resume (returning: value) 30 state = . consumed 31 case . initial : 32 state = . completed (. success (value)) 33 default: 34 break // Already consumed 35 } 36 } 37 } 38 }
Show all 38 lines
Use Case 2: Thread-Safe Flag
Swift Copy
1 class MessageProcessor { 2 @ Atomic private var isProcessing: Bool = false 3 4 func processMessages () async { 5 // Try to set flag - prevents concurrent processing 6 guard $isProcessing. tryToSetFlag () else { 7 return // Already processing 8 } 9 10 defer { 11 _ = $isProcessing. tryToClearFlag () 12 } 13 14 // Process messages... 15 } 16 }
Use Case 3: Atomic Counter
Swift Copy
1 actor MessageSender { 2 @ Atomic private var messagesSent: UInt = 0 3 4 func sendMessage (_ message: Message ) async { 5 // Send message... 6 7 // Atomic increment 8 $messagesSent. update { $ 0 += 1 } 9 } 10 11 func getStats () -> UInt { 12 messagesSent // Thread-safe read 13 } 14 }
Why This Is Better#
Without atomics (race condition):
Swift Copy
1 // Data race - multiple threads can read/write simultaneously 2 class UnsafeCounter : @unchecked Sendable { 3 var count = 0 // Not thread-safe! 4 5 func increment () { 6 count += 1 // Race condition 7 } 8 }
With manual locking (verbose):
Swift Copy
1 // Works but verbose and error-prone 2 class VerboseCounter : @unchecked Sendable { 3 private let lock = NSLock () 4 private var _count = 0 5 6 func increment () { 7 lock. lock () 8 _count += 1 9 lock. unlock () // Easy to forget! 10 } 11 }
Signal's approach:
Swift Copy
1 // Clean, safe, and Sendable 2 class CleanCounter : Sendable { 3 @ Atomic private var count = 0 4 5 func increment () { 6 $count. update { $ 0 += 1 } 7 } 8 9 func tryToSetFlag () -> Bool { 10 do { 11 try $count. transition (from: 0 , to: 1 ) 12 return true 13 } catch { 14 return false 15 } 16 } 17 }
What You Can Learn#
Property wrappers make thread safety ergonomic : Clean syntax without sacrificing safety@unchecked Sendable is OK when you validate safety : As long as implementation is correctSpecialized atomic types are better than generic : AtomicBool.tryToSetFlag() is clearer than generic updateState transitions prevent invalid states : transition(from:to:) ensures valid state machineUnfairLock is faster than NSLock : For simple cases, unfair locks have less overhead
Advanced: UnfairLock Implementation#
Swift Copy
1 // Signal's lock primitive (faster than NSLock) 2 struct UnfairLock { 3 private var _lock = os_unfair_lock () 4 5 mutating func withLock< T >(_ block: () throws -> T ) rethrows -> T { 6 os_unfair_lock_lock (&_lock) 7 defer { os_unfair_lock_unlock (&_lock) } 8 return try block () 9 } 10 }
How to Apply This in Your App#
Start by copying Signal's atomic types into your project, or create simplified versions:
Swift Copy
1 @propertyWrapper 2 struct Atomic < Value >: @unchecked Sendable { 3 private var lock = NSLock () 4 private var value: Value 5 6 init (wrappedValue: Value ) { 7 self . value = wrappedValue 8 } 9 10 var wrappedValue: Value { 11 get { 12 lock. lock () 13 defer { lock. unlock () } 14 return value 15 } 16 set { 17 lock. lock () 18 defer { lock. unlock () } 19 value = newValue 20 } 21 } 22 23 var projectedValue: Atomic < Value > { self } 24 25 mutating func update< T >(_ block: ( inout Value ) -> T ) -> T { 26 lock. lock () 27 defer { lock. unlock () } 28 return block (&value) 29 } 30 } 31 32 // Usage 33 class NetworkManager : Sendable { 34 @ Atomic private var requestCount = 0 35 @ Atomic private var activeRequests: Set < UUID > = [] 36 37 func performRequest () async { 38 let requestId = UUID () 39 40 $activeRequests. update { $ 0 . insert (requestId) } 41 $requestCount. update { $ 0 += 1 } 42 43 defer { 44 $activeRequests. update { $ 0 . remove (requestId) } 45 } 46 47 // Perform request... 48 } 49 }
Show all 49 lines
Use cases:
Shared state in async/await code Counters, flags, and statistics State machines with validated transitions Thread-safe collections
5. Monotonic Clock for Reliable Timing#
The Problem#
Using Date() for timing and duration measurement has a critical flaw: it's affected by system clock changes .
Swift Copy
1 let start = Date () 2 // User adjusts clock backward 1 hour 3 let end = Date () 4 let duration = end. timeIntervalSince (start) // Negative value!
This causes bugs in:
Timeout calculations Rate limiting Performance measurements Session duration tracking
Signal's Solution#
Signal uses monotonic clock , which is guaranteed to never go backward.
Swift Copy
1 /// A date/time value that is monotonically increasing and immune to system clock changes. 2 /// Perfect for measuring durations and timeouts within a single app session. 3 public struct MonotonicDate : Comparable , Sendable , Codable { 4 /// System uptime in nanoseconds (never decreases) 5 private let uptimeNanos: UInt64 6 7 /// Creates a MonotonicDate representing "now" 8 public init () { 9 self . uptimeNanos = clock_gettime_nsec_np ( CLOCK_MONOTONIC_RAW ) 10 } 11 12 /// Creates a MonotonicDate representing a future point in time 13 public init (fromNow interval: TimeInterval ) { 14 let nowNanos = clock_gettime_nsec_np ( CLOCK_MONOTONIC_RAW ) 15 let intervalNanos = UInt64 (interval * 1_000_000_000) 16 self . uptimeNanos = nowNanos + intervalNanos 17 } 18 19 /// Time interval since another MonotonicDate 20 /// Always returns a sensible positive value (or zero) 21 public func timeIntervalSince (_ other: MonotonicDate ) -> TimeInterval { 22 let deltaNanos = self . uptimeNanos - other. uptimeNanos 23 return TimeInterval (deltaNanos) / 1_000_000_000 24 } 25 26 /// Has this date/time passed yet? 27 public var isBeforeNow: Bool { 28 let nowNanos = clock_gettime_nsec_np ( CLOCK_MONOTONIC_RAW ) 29 return uptimeNanos < nowNanos 30 } 31 32 /// Comparable conformance - allows sorting 33 public static func < (lhs: MonotonicDate , rhs: MonotonicDate ) -> Bool { 34 lhs. uptimeNanos < rhs. uptimeNanos 35 } 36 }
Show all 36 lines
How It's Used in Signal#
Use Case 1: Call Duration Tracking
Swift Copy
1 class IndividualCall { 2 private let callConnectedTime: MonotonicDate ? 3 4 var callDuration: TimeInterval { 5 guard let connectedTime = callConnectedTime else { 6 return 0 7 } 8 return MonotonicDate (). timeIntervalSince (connectedTime) 9 } 10 11 func markConnected () { 12 callConnectedTime = MonotonicDate () // Record connection time 13 } 14 }
Result : Call duration is always accurate, even if user adjusts their clock during the call.
Use Case 2: Message Send Timeout
Swift Copy
1 class MessageSender { 2 func sendMessage (_ message: Message ) async throws { 3 let deadline = MonotonicDate (fromNow: 30.0 ) // 30-second timeout 4 5 while !deadline. isBeforeNow { 6 // Try to send... 7 if messageSent { 8 return 9 } 10 11 try await Task . sleep (nanoseconds: 1_000_000_000) // 1 second 12 } 13 14 throw MessageSendError . timeout 15 } 16 }
Use Case 3: Rate Limiting
Swift Copy
1 class RateLimiter { 2 private var lastRequestTime: MonotonicDate ? 3 private let minimumInterval: TimeInterval = 1.0 4 5 func canMakeRequest () -> Bool { 6 guard let lastTime = lastRequestTime else { 7 return true // First request 8 } 9 10 let elapsed = MonotonicDate (). timeIntervalSince (lastTime) 11 return elapsed >= minimumInterval 12 } 13 14 func recordRequest () { 15 lastRequestTime = MonotonicDate () 16 } 17 }
Why This Is Better#
With Date (broken):
Swift Copy
1 // Breaks when clock changes 2 class BrokenTimer { 3 let startTime = Date () 4 5 func checkTimeout () -> Bool { 6 let elapsed = Date (). timeIntervalSince (startTime) 7 return elapsed > 30 // Can be negative! Can jump hours! 8 } 9 } 10 11 // Scenario: 12 // 1. Start timer at 3:00 PM 13 // 2. User sets clock back to 2:00 PM 14 // 3. elapsed becomes -3600 seconds (negative!)
With MonotonicDate (reliable):
Swift Copy
1 // Always increases, immune to clock changes 2 class ReliableTimer { 3 let startTime = MonotonicDate () 4 5 func checkTimeout () -> Bool { 6 let elapsed = MonotonicDate (). timeIntervalSince (startTime) 7 return elapsed > 30 // Always positive, always accurate 8 } 9 }
What You Can Learn#
Never use Date() for durations : Use monotonic clock insteadMonotonic clock is process-scoped : Doesn't persist across app launches (that's what Date is for)CLOCK_MONOTONIC_RAW is available on all platforms : Both iOS and macOSNanosecond precision : More accurate than Date (which uses TimeInterval)Perfect for timeouts, debouncing, rate limiting : Any in-app timing
When to Use Each Type#
Use MonotonicDate for:
Measuring duration in-app (immune to clock changes) Call duration, timeout, debouncing (reliable timing) Performance profiling (accurate measurements)
Use Date for:
Displaying time to user (user expects their local time) Storing timestamp in database (needs to persist across launches) Comparing times across devices (synchronized via server time)
How to Apply This in Your App#
Swift Copy
1 // Copy Signal's implementation or create simplified version 2 struct MonotonicTime : Comparable { 3 private let nanos: UInt64 4 5 init () { 6 self . nanos = clock_gettime_nsec_np ( CLOCK_MONOTONIC_RAW ) 7 } 8 9 static var now: MonotonicTime { MonotonicTime () } 10 11 func elapsed () -> TimeInterval { 12 let nowNanos = clock_gettime_nsec_np ( CLOCK_MONOTONIC_RAW ) 13 return TimeInterval (nowNanos - nanos) / 1_000_000_000 14 } 15 16 static func < (lhs: MonotonicTime , rhs: MonotonicTime ) -> Bool { 17 lhs. nanos < rhs. nanos 18 } 19 } 20 21 // Usage examples 22 class Examples { 23 // Timeout 24 func withTimeout< T >(_ duration: TimeInterval , _ work: () async throws -> T ) async throws -> T { 25 let start = MonotonicTime () 26 27 let result = try await work () 28 29 guard start. elapsed () < duration else { 30 throw TimeoutError () 31 } 32 33 return result 34 } 35 36 // Debouncing 37 class Debouncer { 38 var lastTriggerTime = MonotonicTime () 39 let interval: TimeInterval = 0.3 40 41 func shouldTrigger () -> Bool { 42 let elapsed = lastTriggerTime. elapsed () 43 if elapsed >= interval { 44 lastTriggerTime = MonotonicTime () 45 return true 46 } 47 return false 48 } 49 } 50 51 // Performance measurement 52 func measurePerformance< T >(_ label: String , _ work: () -> T ) -> T { 53 let start = MonotonicTime () 54 let result = work () 55 let duration = start. elapsed () 56 print ( "\(label): \(duration * 1000)ms" ) 57 return result 58 } 59 }
Show all 59 lines
Replace all code that looks like this:
Swift Copy
1 // Before 2 class OldCode { 3 var startTime: Date ? 4 5 func startTimer () { 6 startTime = Date () 7 } 8 9 func checkElapsed () -> TimeInterval { 10 guard let start = startTime else { return 0 } 11 return Date (). timeIntervalSince (start) // BROKEN 12 } 13 }
With this:
Swift Copy
1 // After 2 class NewCode { 3 var startTime: MonotonicTime ? 4 5 func startTimer () { 6 startTime = MonotonicTime () 7 } 8 9 func checkElapsed () -> TimeInterval { 10 guard let start = startTime else { return 0 } 11 return start. elapsed () // RELIABLE 12 } 13 }
6. UICollectionView Frame Protection#
The Problem#
UIKit sometimes temporarily sets view frames to zero during complex layout passes. For UICollectionView, this can cause:
Invalid layout attributes Assertion failures in custom layouts Crashes in background threads calculating cell sizes Incorrect scroll position restoration
This especially happens during:
Split view controller transitions Rotation Keyboard appearance/dismissal View controller presentation/dismissal
Signal's Solution#
Signal overrides frame and bounds setters to reject invalid zero-size assignments, protecting the collection view's layout integrity.
Swift Copy
1 /// Custom UICollectionView that protects against UIKit's temporary zero-frame assignments 2 /// which can cause layout calculation crashes in conversation view 3 class ConversationCollectionView : UICollectionView { 4 5 // MARK: - Frame Protection 6 7 /// Override frame setter to reject invalid values 8 /// UIKit sometimes temporarily sets frames to zero during complex transitions, 9 /// which causes our custom layout to crash when calculating cell sizes 10 override var frame: CGRect { 11 get { 12 super. frame 13 } 14 set { 15 let hasInvalidWidth = newValue. width <= 0 || newValue. width . isNaN 16 let hasInvalidHeight = newValue. height <= 0 || newValue. height . isNaN 17 18 if hasInvalidWidth || hasInvalidHeight { 19 // Log for debugging but don't crash 20 Logger . warn ( "Rejecting invalid frame: \(newValue)" ) 21 22 // Maintain current valid frame 23 return 24 } 25 26 // Only allow valid frames 27 super. frame = newValue 28 } 29 } 30 31 /// Also protect bounds (UIKit can set this independently) 32 override var bounds: CGRect { 33 get { 34 super. bounds 35 } 36 set { 37 let hasInvalidWidth = newValue. width <= 0 || newValue. width . isNaN 38 let hasInvalidHeight = newValue. height <= 0 || newValue. height . isNaN 39 40 if hasInvalidWidth || hasInvalidHeight { 41 Logger . warn ( "Rejecting invalid bounds: \(newValue)" ) 42 return 43 } 44 45 super. bounds = newValue 46 } 47 } 48 49 /// Invariant: Collection view should always have a reasonable size 50 /// This is critical for our custom layout which calculates cell widths as percentages 51 private func assertValidSize () { 52 assert (frame. width > 0 && frame. height > 0 , 53 "ConversationCollectionView should always have valid size" ) 54 } 55 }
Show all 55 lines
How It's Used in Signal#
The Scenario : Signal's conversation view displays messages in a UICollectionView with a complex custom layout.
The custom layout calculates cell widths based on collection view width:
Swift Copy
1 class MessageCellLayout { 2 func calculateCellSize ( for message: Message , 3 collectionViewWidth: CGFloat ) -> CGSize { 4 // Calculate width as percentage of collection view 5 let maxWidth = collectionViewWidth * 0.8 // 80% of screen 6 7 // If collectionViewWidth is 0, this crashes! 8 let textWidth = min (message. textWidth , maxWidth) 9 10 return CGSize (width: textWidth, height: message. height ) 11 } 12 }
Without Protection : During rotation or split view transitions:
UIKit temporarily sets collectionView.frame = .zero Layout pass happens with collectionViewWidth = 0 Cell size calculations produce invalid results Crash: "Invalid layout attributes"
With Protection :
UIKit tries to set collectionView.frame = .zero Custom setter rejects it, maintains previous valid frame Layout calculations use valid width No crash, smooth transition
Real Bug This Prevents#
From Signal's git history, this prevented a crash that looked like:
Code Copy
1 *** Terminating app due to uncaught exception ' NSInternalInconsistencyException ' 2 *** Reason : ' Invalid layout attributes from -[ UICollectionViewLayout layoutAttributesForItemAtIndexPath:]' 3 4 Stack trace: 5 - UICollectionView layoutSubviews 6 - Custom layout calculating cell size with width= 0 7 - NaN propagation through layout calculations 8 - Assertion failure
Why This Is Better#
Without protection (crashes):
Swift Copy
1 // Crashes during complex transitions 2 class NormalCollectionView : UICollectionView { 3 // Uses default frame setter 4 // UIKit can set frame = .zero temporarily 5 // Layout crashes 6 }
With protection (stable):
Swift Copy
1 // Maintains layout integrity 2 class ProtectedCollectionView : UICollectionView { 3 override var frame: CGRect { 4 get { super. frame } 5 set { 6 guard newValue. width > 0 && newValue. height > 0 else { 7 return // Reject invalid frames 8 } 9 super. frame = newValue 10 } 11 } 12 }
What You Can Learn#
UIKit can set temporary invalid frames : Especially during transitionsOverride frame/bounds setters to protect : Valid pattern for complex viewsLog rejections for debugging : Helps identify problematic transitionsMaintain invariants through overrides : Protect your view's contractsCheck for both <= 0 and isNaN : Edge cases exist with floating point
Advanced: Why This Happens in UIKit#
UIKit's layout process sometimes involves:
Swift Copy
1 // UIKit internally during complex transitions: 2 func performComplexTransition () { 3 // 1. Save current frame 4 let savedFrame = view. frame 5 6 // 2. Temporarily set to zero for measurement 7 view. frame = . zero // 8 9 // 3. Ask for intrinsic content size 10 let size = view. intrinsicContentSize 11 12 // 4. Calculate new frame 13 let newFrame = calculateFrame (from: size) 14 15 // 5. Set final frame 16 view. frame = newFrame 17 18 // Problem: If a layout pass happens during step 2-3, 19 // your view has zero size! 20 }
Signal's protection prevents issues during steps 2-3.
How to Apply This in Your App#
Basic Protection :
Swift Copy
1 class ProtectedCollectionView : UICollectionView { 2 override var frame: CGRect { 3 get { super. frame } 4 set { 5 // Reject zero or negative dimensions 6 guard newValue. width > 0 , newValue. height > 0 else { 7 print ( "Rejecting invalid frame: \(newValue)" ) 8 return 9 } 10 super. frame = newValue 11 } 12 } 13 14 override var bounds: CGRect { 15 get { super. bounds } 16 set { 17 guard newValue. width > 0 , newValue. height > 0 else { 18 print ( "Rejecting invalid bounds: \(newValue)" ) 19 return 20 } 21 super. bounds = newValue 22 } 23 } 24 }
Show all 24 lines
Advanced Protection (with NaN checks) :
Swift Copy
1 class RobustCollectionView : UICollectionView { 2 override var frame: CGRect { 3 get { super. frame } 4 set { 5 guard isValid (rect: newValue) else { 6 Logger . warn ( "Rejecting invalid frame: \(newValue)" ) 7 return 8 } 9 super. frame = newValue 10 } 11 } 12 13 private func isValid (rect: CGRect ) -> Bool { 14 // Check for zero or negative 15 guard rect. width > 0 , rect. height > 0 else { 16 return false 17 } 18 19 // Check for NaN (can happen with bad calculations) 20 guard !rect. width . isNaN , !rect. height . isNaN else { 21 return false 22 } 23 24 // Check for infinity (another edge case) 25 guard rect. width . isFinite , rect. height . isFinite else { 26 return false 27 } 28 29 return true 30 } 31 }
Show all 31 lines
When to Use This Pattern :
UICollectionView with custom layout that depends on collection view size UITableView with complex self-sizing cells Custom views that calculate layouts based on their own size Any view experiencing mysterious layout crashes during transitions
Additional Debugging :
Swift Copy
1 class DebuggableCollectionView : UICollectionView { 2 override var frame: CGRect { 3 get { super. frame } 4 set { 5 let oldValue = super. frame 6 7 guard isValid (rect: newValue) else { 8 // Capture stack trace to find who's setting invalid frame 9 let stackTrace = Thread . callStackSymbols 10 Logger . error ( "Invalid frame rejected. Old: \(oldValue), New: \(newValue)\n\(stackTrace)" ) 11 return 12 } 13 14 super. frame = newValue 15 16 // Log significant changes for debugging 17 if abs (newValue. width - oldValue. width ) > 100 { 18 Logger . debug ( "Large frame change: \(oldValue) → \(newValue)" ) 19 } 20 } 21 } 22 }
Show all 22 lines
Use cases:
Chat/messaging apps with custom message layouts Social media feeds with complex cells Split view controllers with collection views Any app with custom UICollectionViewLayout
7. Lazy Dependency Injection with Closures#
The Problem#
Traditional dependency injection requires all dependencies to be created before passing them to a class. This causes problems:
Initialization order issues : Dependency A needs B, but B needs A (circular)Expensive initialization : Creating all dependencies upfront wastes resourcesTesting complexity : Mocking requires creating entire dependency graphTight coupling : Changes to dependencies ripple through initializers
Example of the problem:
Swift Copy
1 // Rigid initialization - all dependencies needed upfront 2 class MessageProcessor { 3 private let database: Database 4 private let networkManager: NetworkManager 5 private let accountManager: AccountManager 6 7 init (database: Database , 8 networkManager: NetworkManager , 9 accountManager: AccountManager ) { 10 self . database = database 11 self . networkManager = networkManager 12 self . accountManager = accountManager 13 } 14 } 15 16 // Creating this requires creating everything first 17 let processor = MessageProcessor ( 18 database: createDatabase (), // Expensive 19 networkManager: createNetworkManager (), // Expensive 20 accountManager: createAccountManager () // Expensive 21 )
Show all 21 lines
Signal's Solution#
Signal uses closure-based lazy dependency injection where dependencies are provided as closures that return the dependency when called.
Swift Copy
1 class DatabaseMigratorRunner { 2 // Dependencies are closures, not concrete instances 3 private let backgroundMessageFetcherFactory: () -> BackgroundMessageFetcherFactory 4 private let remoteConfigManager: () -> any RemoteConfigManager 5 private let tsAccountManager: () -> TSAccountManager 6 7 init ( 8 backgroundMessageFetcherFactory: @escaping () -> BackgroundMessageFetcherFactory , 9 remoteConfigManager: @escaping () -> any RemoteConfigManager , 10 tsAccountManager: @escaping () -> TSAccountManager 11 ) { 12 self . backgroundMessageFetcherFactory = backgroundMessageFetcherFactory 13 self . remoteConfigManager = remoteConfigManager 14 self . tsAccountManager = tsAccountManager 15 } 16 17 func run () async throws { 18 // Dependencies are created only when needed 19 let remoteConfig = remoteConfigManager () 20 try await remoteConfig. refreshIfNeeded () 21 22 // This might not even be called in some code paths 23 if needsBackgroundFetch { 24 let fetcher = backgroundMessageFetcherFactory () 25 await fetcher. fetchMessages () 26 } 27 28 // Each call gets the current instance (could change between calls) 29 let account = tsAccountManager () 30 if account. isRegistered { 31 // ... 32 } 33 } 34 } 35 36 // Usage - pass closures instead of instances 37 let runner = DatabaseMigratorRunner ( 38 backgroundMessageFetcherFactory: { AppEnvironment . shared . backgroundMessageFetcherFactory }, 39 remoteConfigManager: { SSKEnvironment . shared . remoteConfigManager }, 40 tsAccountManager: { DependenciesBridge . shared . tsAccountManager } 41 )
Show all 41 lines
How It's Used in Signal#
Use Case 1: Avoiding Circular Dependencies
Signal has a complex initialization sequence where services depend on each other:
Swift Copy
1 // Database needs account info to decrypt 2 // Account manager needs database to read settings 3 // Network manager needs both 4 5 class AppSetup { 6 func initializeServices () { 7 // Create database first (but don't use account yet) 8 let database = Database () 9 10 // Create account manager with lazy database access 11 let accountManager = AccountManager ( 12 database: { database } // Closure - can be called later 13 ) 14 15 // Create network manager with lazy access to both 16 let networkManager = NetworkManager ( 17 database: { database }, 18 accountManager: { accountManager } 19 ) 20 21 // Now they can all reference each other without circular init issues 22 } 23 }
Show all 23 lines
Use Case 2: Conditional Expensive Operations
Swift Copy
1 class MessageProcessor { 2 // Expensive to create - only create if needed 3 private let attachmentDownloader: () -> AttachmentDownloader 4 5 func processMessage (_ message: Message ) async { 6 if message. hasAttachments { 7 // Only create downloader when actually needed 8 let downloader = attachmentDownloader () 9 await downloader. downloadAttachments ( for : message) 10 } 11 // If no attachments, downloader never created 12 } 13 }
Use Case 3: Getting Current Instance (Mutable Singletons)
Some of Signal's services can be replaced (for testing or feature flags):
Swift Copy
1 class FeatureFlag { 2 // The implementation can change at runtime 3 private let accountManager: () -> TSAccountManager 4 5 var isEnabled: Bool { 6 // Always get current instance 7 let manager = accountManager () 8 return manager. currentAccount != nil 9 } 10 } 11 12 // In tests, you can swap out the account manager 13 func testFeature () { 14 let mockManager = MockAccountManager () 15 16 // Closure returns different instance for testing 17 let flag = FeatureFlag ( 18 accountManager: { mockManager } 19 ) 20 21 XCTAssertTrue (flag. isEnabled ) 22 }
Show all 22 lines
Why This Is Better#
Traditional DI (inflexible):
Swift Copy
1 // All dependencies created upfront 2 class Traditional { 3 let heavy1: HeavyService 4 let heavy2: HeavyService 5 let heavy3: HeavyService 6 7 init (heavy1: HeavyService , heavy2: HeavyService , heavy3: HeavyService ) { 8 self . heavy1 = heavy1 // Created even if never used 9 self . heavy2 = heavy2 10 self . heavy3 = heavy3 11 } 12 } 13 14 // Creating this is expensive 15 let obj = Traditional ( 16 heavy1: HeavyService (), // 100ms 17 heavy2: HeavyService (), // 100ms 18 heavy3: HeavyService () // 100ms - might not even be used! 19 )
Signal's approach (flexible):
Swift Copy
1 // Dependencies created only when needed 2 class Lazy { 3 let heavy1: () -> HeavyService 4 let heavy2: () -> HeavyService 5 let heavy3: () -> HeavyService 6 7 init (heavy1: @escaping () -> HeavyService , 8 heavy2: @escaping () -> HeavyService , 9 heavy3: @escaping () -> HeavyService ) { 10 self . heavy1 = heavy1 // Just storing closure - instant 11 self . heavy2 = heavy2 12 self . heavy3 = heavy3 13 } 14 15 func doSomething () { 16 if condition { 17 heavy1 (). doWork () // Created only if condition met 18 } 19 } 20 } 21 22 // Initialization is instant 23 let obj = Lazy ( 24 heavy1: { HeavyService () }, // Not created yet 25 heavy2: { HeavyService () }, 26 heavy3: { HeavyService () } 27 )
Show all 27 lines
What You Can Learn#
Closures enable lazy initialization : Don't create until neededBreaks circular dependencies : A can reference B, B can reference AImproves testability : Easy to inject different instancesReduces memory footprint : Only create what you useAllows runtime dependency changes : Closures can return different instances
Pattern Variations#
1. With Default Values :
Swift Copy
1 class Service { 2 private let dependency: () -> Dependency 3 4 init (dependency: @escaping () -> Dependency = { DefaultDependency . shared }) { 5 self . dependency = dependency 6 } 7 }
2. With Memoization :
Swift Copy
1 class Service { 2 private let dependencyFactory: () -> Dependency 3 private lazy var dependency: Dependency = dependencyFactory () 4 5 init (dependency: @escaping () -> Dependency ) { 6 self . dependencyFactory = dependency 7 } 8 9 func doWork () { 10 // Only created once, then cached 11 dependency. performAction () 12 } 13 }
3. With Type-Erased Protocol :
Swift Copy
1 class Service { 2 private let dependency: () -> any DependencyProtocol 3 4 init (dependency: @escaping () -> any DependencyProtocol ) { 5 self . dependency = dependency 6 } 7 }
How to Apply This in Your App#
Basic Pattern :
Swift Copy
1 class MyViewController : UIViewController { 2 // Instead of: private let database: Database 3 private let database: () -> Database 4 5 // Instead of: private let networkManager: NetworkManager 6 private let networkManager: () -> NetworkManager 7 8 init ( 9 database: @escaping () -> Database , 10 networkManager: @escaping () -> NetworkManager 11 ) { 12 self . database = database 13 self . networkManager = networkManager 14 super. init (nibName: nil , bundle: nil ) 15 } 16 17 func loadData () async { 18 // Create when needed 19 let db = database () 20 let data = await db. fetch () 21 22 // Send to server 23 let network = networkManager () 24 try await network. upload (data) 25 } 26 } 27 28 // Usage 29 let vc = MyViewController ( 30 database: { AppDelegate . shared . database }, 31 networkManager: { AppDelegate . shared . networkManager } 32 )
Show all 32 lines
Advanced: Solving Circular Dependencies :
Swift Copy
1 class ServiceA { 2 private let serviceB: () -> ServiceB 3 4 init (serviceB: @escaping () -> ServiceB ) { 5 self . serviceB = serviceB 6 } 7 8 func doSomething () { 9 serviceB (). helperMethod () 10 } 11 } 12 13 class ServiceB { 14 private let serviceA: () -> ServiceA 15 16 init (serviceA: @escaping () -> ServiceA ) { 17 self . serviceA = serviceA 18 } 19 20 func doSomethingElse () { 21 serviceA (). helperMethod () 22 } 23 } 24 25 // No circular init issue! 26 var serviceA: ServiceA ! 27 var serviceB: ServiceB ! 28 29 serviceA = ServiceA (serviceB: { serviceB }) 30 serviceB = ServiceB (serviceA: { serviceA })
Show all 30 lines
Testing :
Swift Copy
1 class MyViewControllerTests : XCTestCase { 2 func testDataLoading () async { 3 let mockDatabase = MockDatabase () 4 let mockNetwork = MockNetworkManager () 5 6 let vc = MyViewController ( 7 database: { mockDatabase }, 8 networkManager: { mockNetwork } 9 ) 10 11 await vc. loadData () 12 13 XCTAssertTrue (mockDatabase. fetchCalled ) 14 XCTAssertTrue (mockNetwork. uploadCalled ) 15 } 16 }
Use cases:
Complex dependency graphs with circular references Expensive services that aren't always needed Services that need to be swappable for testing Apps with feature flags that change implementations
8. Context-Aware LRU Caching#
The Problem#
iOS apps often run in multiple process contexts:
Main app : Has plenty of memory (hundreds of MB)Notification Service Extension (NSE) : Limited to ~24MBShare Extension : Limited to ~120MBWidgets : Very limited memory
Using the same cache sizes across all contexts causes:
Memory pressure and crashes in extensions Wasted memory in main app (cache too small) Poor performance tuning
Signal's Solution#
Signal's caching layer is context-aware and automatically adjusts cache sizes based on the execution environment.
Swift Copy
1 /// LRU Cache that adjusts size based on app context 2 /// Main app gets large cache, NSE gets smaller cache 3 public class LRUCache < KeyType : Hashable & Equatable , ValueType > { 4 private let cache: NSCache < AnyObject , AnyObject > 5 private let maxSize: Int 6 private let nseMaxSize: Int 7 8 public init ( 9 maxSize: Int , 10 nseMaxSize: Int ? = nil , 11 shouldEvacuateInBackground: Bool = true 12 ) { 13 self . maxSize = maxSize 14 self . nseMaxSize = nseMaxSize ?? max ( 4 , maxSize / 8 ) // Default: 1/8th size 15 16 self . cache = NSCache () 17 self . cache . countLimit = CurrentAppContext (). isNSE ? self . nseMaxSize : self . maxSize 18 19 // Clear cache when app backgrounds to free memory 20 if shouldEvacuateInBackground { 21 NotificationCenter . default . addObserver ( 22 forName: . OWSApplicationDidEnterBackground , 23 object: nil , 24 queue: nil 25 ) { [weak self ] _ in 26 self ?. removeAllObjects () 27 } 28 } 29 } 30 31 public func get (key: KeyType ) -> ValueType ? { 32 guard let value = cache. object (forKey: key as AnyObject ) as? ValueType else { 33 return nil 34 } 35 return value 36 } 37 38 public func set (key: KeyType , value: ValueType ) { 39 cache. setObject (value as AnyObject , forKey: key as AnyObject ) 40 } 41 42 public func removeAllObjects () { 43 cache. removeAllObjects () 44 } 45 } 46 47 // CurrentAppContext determines execution environment 48 protocol AppContext { 49 var isMainApp: Bool { get } 50 var isNSE: Bool { get } 51 var isShareExtension: Bool { get } 52 var reportedMemoryWarnings: Bool { get } 53 }
Show all 53 lines
How It's Used in Signal#
Use Case 1: Profile Image Cache
Swift Copy
1 class ProfileImageCache { 2 // Main app: Cache 256 profile images (~50MB) 3 // NSE: Cache 32 profile images (~6MB) 4 private let imageCache = LRUCache < String , UIImage >( 5 maxSize: 256 , 6 nseMaxSize: 32 7 ) 8 9 func profileImage ( for address: SignalServiceAddress ) -> UIImage ? { 10 let key = address. stringValue 11 12 if let cached = imageCache. get (key: key) { 13 return cached // Cache hit 14 } 15 16 // Load from disk 17 guard let image = loadFromDisk (address) else { 18 return nil 19 } 20 21 // Cache for next time (automatically size-aware) 22 imageCache. set (key: key, value: image) 23 return image 24 } 25 }
Show all 25 lines
When NSE processes notification :
Limited to 32 profile images in cache Prevents memory pressure Still gets performance benefit of caching
When main app shows conversation list :
Can cache up to 256 profile images Smooth scrolling performance Better UX with larger cache
Use Case 2: Model Read Cache
Swift Copy
1 class ThreadModelCache { 2 // Main app: Cache 64 thread models 3 // NSE: Cache 8 thread models 4 private let cache = LRUCache < String , TSThread >( 5 maxSize: 64 , 6 nseMaxSize: 8 7 ) 8 9 func thread ( for uniqueId: String , tx: DBReadTransaction ) -> TSThread ? { 10 if let cached = cache. get (key: uniqueId) { 11 return cached 12 } 13 14 guard let thread = TSThread . anyFetch (uniqueId: uniqueId, transaction: tx) else { 15 return nil 16 } 17 18 cache. set (key: uniqueId, value: thread) 19 return thread 20 } 21 }
Show all 21 lines
Use Case 3: Attachment Thumbnail Cache
Swift Copy
1 class ThumbnailCache { 2 // Main app: Cache 512 thumbnails (~100MB) 3 // NSE: Cache 16 thumbnails (~3MB) 4 // Share extension: Cache 64 thumbnails (~12MB) 5 private let cache: LRUCache < String , UIImage > 6 7 init () { 8 let nseSize = 16 9 let shareExtensionSize = 64 10 let mainAppSize = 512 11 12 let maxSize: Int 13 if CurrentAppContext (). isNSE { 14 maxSize = nseSize 15 } else if CurrentAppContext (). isShareExtension { 16 maxSize = shareExtensionSize 17 } else { 18 maxSize = mainAppSize 19 } 20 21 self . cache = LRUCache (maxSize: maxSize, nseMaxSize: nseSize) 22 } 23 }
Show all 23 lines
Why This Is Better#
Without context awareness (crashes in NSE):
Swift Copy
1 // Same cache size everywhere 2 class NaiveCache { 3 private let cache = NSCache < NSString , UIImage >() 4 5 init () { 6 cache. countLimit = 500 // Too large for NSE! 7 } 8 } 9 10 // In NSE processing notification: 11 // - Loads 500 profile images 12 // - Uses 100MB+ of memory 13 // - NSE limit is ~24MB 14 // - Crash: "Extension terminated due to memory pressure"
With context awareness (stable):
Swift Copy
1 // Adapts to environment 2 class SmartCache { 3 private let cache = NSCache < NSString , UIImage >() 4 5 init () { 6 if CurrentAppContext (). isNSE { 7 cache. countLimit = 32 // Small for NSE 8 } else { 9 cache. countLimit = 500 // Large for main app 10 } 11 } 12 }
Advanced: Memory Pressure Response#
Signal also evacuates caches when memory warnings occur:
Swift Copy
1 class LRUCache < K : Hashable , V > { 2 init (maxSize: Int , shouldEvacuateInBackground: Bool = true ) { 3 // ... 4 5 // Clear cache on memory warning 6 NotificationCenter . default . addObserver ( 7 forName: UIApplication . didReceiveMemoryWarningNotification , 8 object: nil , 9 queue: nil 10 ) { [weak self ] _ in 11 Logger . warn ( "Memory warning - evacuating cache" ) 12 self ?. removeAllObjects () 13 } 14 15 // Clear cache when backgrounding (free memory) 16 if shouldEvacuateInBackground { 17 NotificationCenter . default . addObserver ( 18 forName: . OWSApplicationDidEnterBackground , 19 object: nil , 20 queue: nil 21 ) { [weak self ] _ in 22 self ?. removeAllObjects () 23 } 24 } 25 } 26 }
Show all 26 lines
What You Can Learn#
Extensions have strict memory limits : NSE ~24MB, Share ~120MBNSCache is better than Dictionary : Automatic eviction under memory pressureContext detection is essential : Don't treat all processes equallyBackground evacuation helps : Clear caches when app backgroundsMemory warnings should clear caches : System is telling you to free memory
Memory Limits by Extension Type#
Notification Service Extension
Memory limit: ~24MB Recommended cache size: Very small (8-32 items)
Share Extension
Memory limit: ~120MB Recommended cache size: Small (64-128 items)
Widget
Memory limit: ~30MB Recommended cache size: Very small (16-32 items)
Main App
Memory limit: ~300MB+ Recommended cache size: Large (256-1024 items)
How to Apply This in Your App#
Basic Context-Aware Cache :
Swift Copy
1 import Foundation 2 3 class ContextAwareCache < Key : Hashable , Value > { 4 private let cache = NSCache < AnyHashable , AnyObject >() 5 6 init (mainAppLimit: Int , extensionLimit: Int ) { 7 // Detect if running in extension 8 let isExtension = Bundle . main . bundlePath . hasSuffix ( ".appex" ) 9 10 cache. countLimit = isExtension ? extensionLimit : mainAppLimit 11 12 // Clear on memory warning 13 NotificationCenter . default . addObserver ( 14 forName: UIApplication . didReceiveMemoryWarningNotification , 15 object: nil , 16 queue: nil 17 ) { [weak cache] _ in 18 cache?. removeAllObjects () 19 } 20 } 21 22 func get (_ key: Key ) -> Value ? { 23 cache. object (forKey: key as AnyHashable ) as? Value 24 } 25 26 func set (_ key: Key , value: Value ) { 27 cache. setObject (value as AnyObject , forKey: key as AnyHashable ) 28 } 29 30 func clear () { 31 cache. removeAllObjects () 32 } 33 } 34 35 // Usage 36 let imageCache = ContextAwareCache < String , UIImage >( 37 mainAppLimit: 500 , 38 extensionLimit: 32 39 )
Show all 39 lines
Advanced: Memory Cost Aware :
Swift Copy
1 class SizeAwareCache < Key : Hashable , Value > { 2 private let cache = NSCache < AnyHashable , AnyObject >() 3 4 init (mainAppMemoryMB: Int , extensionMemoryMB: Int ) { 5 let isExtension = Bundle . main . bundlePath . hasSuffix ( ".appex" ) 6 7 let memoryLimit = isExtension ? extensionMemoryMB : mainAppMemoryMB 8 cache. totalCostLimit = memoryLimit * 1024 * 1024 // Convert to bytes 9 } 10 11 func set (_ key: Key , value: Value , costInBytes: Int ) { 12 cache. setObject (value as AnyObject , forKey: key as AnyHashable , cost: costInBytes) 13 } 14 } 15 16 // Usage 17 let imageCache = SizeAwareCache < String , UIImage >( 18 mainAppMemoryMB: 50 , // 50MB for main app 19 extensionMemoryMB: 5 // 5MB for extension 20 ) 21 22 // Store with actual memory cost 23 let image = UIImage (...) 24 let imageSize = image. pngData ()?. count ?? 0 25 imageCache. set ( "key" , value: image, costInBytes: imageSize)
Show all 25 lines
Production-Ready with Logging :
Swift Copy
1 class ProductionCache < Key : Hashable , Value > { 2 private let cache = NSCache < AnyHashable , AnyObject >() 3 private let name: String 4 5 init (name: String , mainAppLimit: Int , extensionLimit: Int ) { 6 self . name = name 7 8 let isExtension = Bundle . main . bundlePath . hasSuffix ( ".appex" ) 9 let limit = isExtension ? extensionLimit : mainAppLimit 10 11 cache. countLimit = limit 12 cache. delegate = self 13 14 print ( "[\(name)] Initialized with limit: \(limit) (\(isExtension ? " extension " : " main app "))" ) 15 } 16 } 17 18 extension ProductionCache : NSCacheDelegate { 19 func cache (_ cache: NSCache < AnyHashable , AnyObject >, willEvictObject obj: Any ) { 20 print ( "[\(name)] Evicting object due to memory pressure" ) 21 } 22 }
Show all 22 lines
Use cases:
Apps with Notification Service Extensions Apps with Share Extensions Apps with Widgets Any app that needs to optimize memory per context Image/media caching Model/data caching
9. Cross-Process Cache Invalidation#
The Problem#
Signal iOS has multiple processes sharing the same database:
Main app : User actively using the appNotification Service Extension (NSE) : Processing incoming message notificationsShare Extension : Sharing content to Signal
When the NSE receives a message and writes it to the database, the main app's in-memory caches become stale. Without invalidation:
Main app shows outdated data User sees message delay (cache shows old conversation list) Race conditions and data inconsistencies
Standard solutions don't work:
NotificationCenter is process-local onlyDatabase triggers can't notify other processes Polling wastes resources
Signal's Solution#
Signal uses Darwin notifications (system-level cross-process notifications) to invalidate caches when any process modifies the database.
Swift Copy
1 /// Read cache that automatically invalidates when another process modifies the database 2 class ModelReadCache < KeyType : Hashable , ValueType > { 3 private let cache: LRUCache < KeyType , ValueType > 4 private let cacheIdentifier: String 5 6 // Darwin notification name (system-wide) 7 private var darwinNotificationName: String { 8 "org.signal.database.didChange.\(cacheIdentifier)" 9 } 10 11 init (cacheIdentifier: String , maxSize: Int , nseMaxSize: Int ) { 12 self . cacheIdentifier = cacheIdentifier 13 self . cache = LRUCache (maxSize: maxSize, nseMaxSize: nseMaxSize) 14 15 // Register for cross-process database change notifications 16 registerForCrossProcessNotifications () 17 18 // Also register for in-process notifications (faster) 19 NotificationCenter . default . addObserver ( 20 self , 21 selector: # selector (handleInProcessDatabaseChange), 22 name: . DatabaseDidChange , 23 object: nil 24 ) 25 } 26 27 private func registerForCrossProcessNotifications () { 28 // Darwin notifications work across process boundaries 29 let center = CFNotificationCenterGetDarwinNotifyCenter () 30 31 CFNotificationCenterAddObserver ( 32 center, 33 Unmanaged . passUnretained ( self ). toOpaque (), 34 { (center, observer, name, object, userInfo) in 35 guard let observer = observer else { return } 36 let cache = Unmanaged < ModelReadCache >. fromOpaque (observer). takeUnretainedValue () 37 cache. handleCrossProcessDatabaseChange () 38 }, 39 darwinNotificationName as CFString , 40 nil , 41 . deliverImmediately 42 ) 43 } 44 45 @objc private func handleInProcessDatabaseChange () { 46 // Same process modified database - invalidate immediately 47 cache. removeAllObjects () 48 } 49 50 private func handleCrossProcessDatabaseChange () { 51 // Another process modified database - invalidate cache 52 DispatchQueue . main . async { 53 self . cache . removeAllObjects () 54 55 // Notify UI to refresh 56 NotificationCenter . default . post (name: . ModelCacheDidInvalidate , object: nil ) 57 } 58 } 59 60 // Read from cache or database 61 func read (key: KeyType , transaction: DBReadTransaction ) -> ValueType ? { 62 // Check cache first 63 if let cached = cache. get (key: key) { 64 return cached 65 } 66 67 // Cache miss - read from database 68 guard let value = readFromDatabase (key: key, transaction: transaction) else { 69 return nil 70 } 71 72 // Populate cache for next read 73 cache. set (key: key, value: value) 74 return value 75 } 76 } 77 78 // Write path posts Darwin notification 79 class Database { 80 func write (_ block: ( DBWriteTransaction ) -> Void ) { 81 performWrite (block) 82 83 // Notify all processes that database changed 84 postCrossProcessDatabaseChangeNotification () 85 } 86 87 private func postCrossProcessDatabaseChangeNotification () { 88 let center = CFNotificationCenterGetDarwinNotifyCenter () 89 let notificationName = "org.signal.database.didChange" as CFString 90 91 CFNotificationCenterPostNotification ( 92 center, 93 CFNotificationName (notificationName), 94 nil , 95 nil , 96 true // Deliver immediately 97 ) 98 } 99 }
Show all 99 lines
How It's Used in Signal#
Scenario: Message Arrives While App is in Background
NSE Process receives push notificationNSE decrypts message and writes to databaseNSE posts Darwin notificationMain App (suspended) receives Darwin notificationMain App invalidates thread list cacheUser opens app → Main App reads fresh data from database New message appears immediately
Swift Copy
1 class ThreadModelCache { 2 private let cache: ModelReadCache < String , TSThread > 3 4 init () { 5 // Main app: cache 64 threads 6 // NSE: cache 8 threads 7 self . cache = ModelReadCache ( 8 cacheIdentifier: "threads" , 9 maxSize: 64 , 10 nseMaxSize: 8 11 ) 12 } 13 14 func getThread (uniqueId: String , transaction: DBReadTransaction ) -> TSThread ? { 15 // Automatically uses cache if valid 16 // Automatically invalidates if another process wrote to DB 17 return cache. read (key: uniqueId, transaction: transaction) 18 } 19 }
Swift Copy
1 class SignalRecipientCache { 2 private let cache: ModelReadCache < SignalServiceAddress , SignalRecipient > 3 4 init () { 5 self . cache = ModelReadCache ( 6 cacheIdentifier: "recipients" , 7 maxSize: 256 , 8 nseMaxSize: 32 9 ) 10 } 11 12 func recipient ( for address: SignalServiceAddress , tx: DBReadTransaction ) -> SignalRecipient ? { 13 cache. read (key: address, transaction: tx) 14 } 15 }
Real-World Flow Diagram#
Code Copy
1 ┌─────────────────────────────────────────────────────────────────┐ 2 │ Time : 10 : 00 : 00 AM - User viewing conversation list ( Main App ) │ 3 └─────────────────────────────────────────────────────────────────┘ 4 │ 5 │ Main App caches thread list in memory 6 ▼ 7 ┌─────────────────────────────────────────────────────────────────┐ 8 │ Time : 10 : 00 : 05 AM - User presses home button ( App backgrounds) │ 9 └─────────────────────────────────────────────────────────────────┘ 10 │ 11 ▼ 12 ┌─────────────────────────────────────────────────────────────────┐ 13 │ Time : 10 : 00 : 10 AM - New message arrives ( NSE wakes up) │ 14 │ NSE Process : │ 15 │ 1 . Decrypt message │ 16 │ 2 . Write to database: INSERT INTO messages ... │ 17 │ 3 . Update thread: UPDATE threads SET lastMessageDate = ... │ 18 │ 4 . Post Darwin notification: "org.signal.database.didChange" │ 19 └─────────────────────────────────────────────────────────────────┘ 20 │ 21 │ Darwin notification crosses process boundary 22 ▼ 23 ┌─────────────────────────────────────────────────────────────────┐ 24 │ Time : 10 : 00 : 10 AM - Main App receives Darwin notification │ 25 │ Main App (suspended): │ 26 │ 1 . Darwin notification handler fires │ 27 │ 2 . Invalidate thread cache │ 28 │ 3 . Invalidate message cache │ 29 └─────────────────────────────────────────────────────────────────┘ 30 │ 31 ▼ 32 ┌─────────────────────────────────────────────────────────────────┐ 33 │ Time : 10 : 00 : 15 AM - User opens app again │ 34 │ Main App : │ 35 │ 1 . Render conversation list │ 36 │ 2 . Check thread cache → MISS (was invalidated) │ 37 │ 3 . Read from database → Gets fresh data with new message │ 38 │ 4 . Display shows new message immediately ✓ │ 39 └─────────────────────────────────────────────────────────────────┘
Show all 39 lines
Why This Is Better#
Without cross-process invalidation (stale data):
Swift Copy
1 // Main app caches become stale when NSE writes 2 class NaiveThreadCache { 3 private var cache: [ String : TSThread ] = [:] 4 5 func getThread (_ id: String ) -> TSThread ? { 6 if let cached = cache[id] { 7 return cached // STALE! NSE modified database but cache doesn't know 8 } 9 10 let thread = database. fetch (id) 11 cache[id] = thread 12 return thread 13 } 14 } 15 16 // User experience: 17 // - Message arrives (NSE processes it) 18 // - User opens app 19 // - Conversation list doesn't show new message (stale cache) 20 // - User has to force refresh or wait for cache timeout
With cross-process invalidation (always fresh):
Swift Copy
1 // Cache automatically invalidates when ANY process writes 2 class SmartThreadCache { 3 private let cache: ModelReadCache < String , TSThread > 4 5 func getThread (_ id: String , tx: DBReadTransaction ) -> TSThread ? { 6 // Cache automatically invalidated by NSE write 7 return cache. read (key: id, transaction: tx) 8 } 9 } 10 11 // User experience: 12 // - Message arrives (NSE processes it) 13 // - NSE posts Darwin notification 14 // - Main app cache invalidates 15 // - User opens app 16 // - Conversation list immediately shows new message ✓
What You Can Learn#
Darwin notifications are cross-process : Work between app and extensionsCFNotificationCenter != NotificationCenter : Different APIs, different scopesInvalidation is cheaper than polling : Don't poll database for changesCache per-model-type : Separate caches for threads, messages, contactsCombine Darwin + local notifications : Fast path for same-process changes
Darwin Notifications Deep Dive#
Key characteristics:
System-level notifications (not limited to your app) Work across all processes (app, extensions, background processes) No payload (just the notification name) Delivered immediately (no queueing) Persist even if observer isn't running (delivered when it starts)
API Comparison:
NotificationCenter:
Scope: Single process Payload: Yes (userInfo dictionary) Performance: Faster Use case: In-process events
Darwin Notifications:
Scope: System-wide Payload: No (name only) Performance: Slightly slower Use case: Cross-process events
How to Apply This in Your App#
Basic Cross-Process Notification :
Swift Copy
1 class CrossProcessNotifier { 2 static let shared = CrossProcessNotifier () 3 4 // Post notification (any process can call this) 5 func postDatabaseChanged () { 6 let center = CFNotificationCenterGetDarwinNotifyCenter () 7 let name = "com.yourapp.database.changed" as CFString 8 9 CFNotificationCenterPostNotification ( 10 center, 11 CFNotificationName (name), 12 nil , 13 nil , 14 true // deliverImmediately 15 ) 16 } 17 18 // Observe notification (any process can observe) 19 func observeDatabaseChanges (callback: @escaping () -> Void ) { 20 let center = CFNotificationCenterGetDarwinNotifyCenter () 21 let name = "com.yourapp.database.changed" as CFString 22 23 // Create observer context 24 class ObserverContext { 25 let callback: () -> Void 26 init (callback: @escaping () -> Void ) { 27 self . callback = callback 28 } 29 } 30 31 let context = ObserverContext (callback: callback) 32 let observer = Unmanaged . passRetained (context). toOpaque () 33 34 CFNotificationCenterAddObserver ( 35 center, 36 observer, 37 { (center, observer, name, object, userInfo) in 38 guard let observer = observer else { return } 39 let context = Unmanaged < ObserverContext >. fromOpaque (observer). takeUnretainedValue () 40 41 DispatchQueue . main . async { 42 context. callback () 43 } 44 }, 45 name, 46 nil , 47 . deliverImmediately 48 ) 49 } 50 } 51 52 // Usage in Main App: 53 class MainAppCache { 54 init () { 55 CrossProcessNotifier . shared . observeDatabaseChanges { [weak self ] in 56 print ( "Database changed by another process!" ) 57 self ?. clearCache () 58 } 59 } 60 } 61 62 // Usage in NSE: 63 class NotificationService : UNNotificationServiceExtension { 64 override func didReceive (_ request: UNNotificationRequest ) { 65 // Process message... 66 database. insert (message) 67 68 // Notify main app 69 CrossProcessNotifier . shared . postDatabaseChanged () 70 } 71 }
Show all 71 lines
Production-Ready Cache with Invalidation :
Swift Copy
1 class CrossProcessCache < Key : Hashable , Value > { 2 private let cache = NSCache < AnyHashable , AnyObject >() 3 private let darwinNotificationName: String 4 5 init (identifier: String , maxSize: Int ) { 6 self . darwinNotificationName = "com.yourapp.cache.\(identifier)" 7 8 cache. countLimit = maxSize 9 10 // Register for invalidation notifications 11 registerForInvalidation () 12 } 13 14 private func registerForInvalidation () { 15 let center = CFNotificationCenterGetDarwinNotifyCenter () 16 let name = darwinNotificationName as CFString 17 18 CFNotificationCenterAddObserver ( 19 center, 20 Unmanaged . passUnretained ( self ). toOpaque (), 21 { (center, observer, name, object, userInfo) in 22 guard let observer = observer else { return } 23 let cache = Unmanaged < CrossProcessCache >. fromOpaque (observer). takeUnretainedValue () 24 25 DispatchQueue . main . async { 26 print ( "[\(cache.darwinNotificationName)] Invalidating due to cross-process change" ) 27 cache. cache . removeAllObjects () 28 } 29 }, 30 name, 31 nil , 32 . deliverImmediately 33 ) 34 } 35 36 func get (_ key: Key ) -> Value ? { 37 cache. object (forKey: key as AnyHashable ) as? Value 38 } 39 40 func set (_ key: Key , value: Value ) { 41 cache. setObject (value as AnyObject , forKey: key as AnyHashable ) 42 } 43 44 func invalidateAllProcesses () { 45 // Clear local cache 46 cache. removeAllObjects () 47 48 // Notify other processes 49 let center = CFNotificationCenterGetDarwinNotifyCenter () 50 let name = darwinNotificationName as CFString 51 52 CFNotificationCenterPostNotification (center, CFNotificationName (name), nil , nil , true ) 53 } 54 55 deinit { 56 let center = CFNotificationCenterGetDarwinNotifyCenter () 57 CFNotificationCenterRemoveEveryObserver (center, Unmanaged . passUnretained ( self ). toOpaque ()) 58 } 59 } 60 61 // Usage: 62 let threadCache = CrossProcessCache < String , Thread >(identifier: "threads" , maxSize: 100 ) 63 64 // In NSE after writing message: 65 database. write { tx in 66 tx. insert (message) 67 } 68 threadCache. invalidateAllProcesses () // Invalidates main app's cache too!
Show all 68 lines
Use cases:
Apps with Notification Service Extensions Apps with Share Extensions Core Data or SQLite shared between processes Any cross-process data synchronization Cache invalidation between app and extensions
10. AppReadiness Launch Coordination#
The Problem#
iOS apps have complex initialization requirements:
Database migrations Keychain access Network reachability checks User settings restoration Cloud sync Push notification registration Analytics setup Third-party SDKs
If all of these run immediately on launch:
Main thread blocks → User sees frozen launch screenWatchdog timer kills app → 0x8badf00d crash on older devicesPoor user experience → App feels unresponsiveMemory spikes → Simultaneous initialization causes pressure
Signal's Solution#
Signal uses an AppReadiness coordination system that:
Defines multiple readiness levels Allows tasks to register for specific readiness stages Spreads initialization over time ("polite" async tasks) Prevents the dreaded "stampede" of simultaneous work
Swift Copy
1 /// Coordinates app initialization to prevent launch stampede 2 @objc 3 public class AppReadiness : NSObject { 4 5 // MARK: - Readiness Levels 6 7 /// App is ready - database is accessible 8 private static var isAppReady = false 9 10 /// UI is ready - safe to present view controllers 11 private static var isUIReady = false 12 13 /// Registered blocks waiting for readiness 14 private static var appReadyBlocks: [() -> Void ] = [] 15 private static var uiReadyBlocks: [() -> Void ] = [] 16 private static var politeAsyncBlocks: [() -> Void ] = [] 17 18 // MARK: - Registration 19 20 /// Run block when app becomes ready (or immediately if already ready) 21 @objc 22 public class func runNowOrWhenAppDidBecomeReady (_ block: @escaping () -> Void ) { 23 guard !isAppReady else { 24 // Already ready - run immediately 25 block () 26 return 27 } 28 29 // Not ready yet - queue for later 30 appReadyBlocks. append (block) 31 } 32 33 /// Run block when UI becomes ready (or immediately if already ready) 34 @objc 35 public class func runNowOrWhenUIDidBecomeReady (_ block: @escaping () -> Void ) { 36 guard !isUIReady else { 37 block () 38 return 39 } 40 41 uiReadyBlocks. append (block) 42 } 43 44 /// Run block asynchronously after app is ready, with delay to be "polite" 45 /// Use this for non-critical initialization to spread CPU load 46 @objc 47 public class func runNowOrWhenAppDidBecomeReadyAsync (_ block: @escaping () -> Void ) { 48 guard !isAppReady else { 49 // Already ready - run politely (with delay) 50 runPolitely (block) 51 return 52 } 53 54 // Not ready yet - queue for polite execution later 55 politeAsyncBlocks. append (block) 56 } 57 58 // MARK: - Readiness Signaling 59 60 /// Mark app as ready - triggers queued blocks 61 @objc 62 public class func setAppIsReady () { 63 assert (!isAppReady, "App is already ready" ) 64 isAppReady = true 65 66 // Run all queued app-ready blocks 67 let blocks = appReadyBlocks 68 appReadyBlocks = [] 69 70 for block in blocks { 71 block () 72 } 73 74 // Run polite blocks with delay to spread load 75 let politeBlocks = politeAsyncBlocks 76 politeAsyncBlocks = [] 77 78 for block in politeBlocks { 79 runPolitely (block) 80 } 81 } 82 83 /// Mark UI as ready - triggers UI-related blocks 84 @objc 85 public class func setUIIsReady () { 86 assert (isAppReady, "UI ready called before app ready" ) 87 assert (!isUIReady, "UI is already ready" ) 88 isUIReady = true 89 90 let blocks = uiReadyBlocks 91 uiReadyBlocks = [] 92 93 for block in blocks { 94 block () 95 } 96 } 97 98 // MARK: - Polite Execution 99 100 /// Run block asynchronously with delay to be "polite" (spread CPU load) 101 private class func runPolitely (_ block: @escaping () -> Void ) { 102 // Random delay between 0.1-1.0 seconds 103 // Spreads initialization over time instead of all at once 104 let delay = TimeInterval . random ( in : 0.1 ... 1.0 ) 105 106 DispatchQueue . main . asyncAfter (deadline: . now () + delay) { 107 block () 108 } 109 } 110 }
Show all 110 lines
How It's Used in Signal#
Initialization Sequence
Swift Copy
1 class AppDelegate : UIResponder , UIApplicationDelegate { 2 func application (_ application: UIApplication , 3 didFinishLaunchingWithOptions options: [ UIApplication . LaunchOptionsKey : Any ]?) -> Bool { 4 5 // Phase 1: Critical setup (blocks UI) 6 setupLogging () 7 setupCrashReporting () 8 9 // Phase 2: Database (required for everything else) 10 setupDatabase () 11 runDatabaseMigrations () 12 13 // Phase 3: Mark app ready 14 AppReadiness . setAppIsReady () // ← Triggers queued blocks 15 16 // Phase 4: UI setup 17 setupRootViewController () 18 19 // Phase 5: Mark UI ready 20 AppReadiness . setUIIsReady () // ← Triggers UI blocks 21 22 return true 23 } 24 }
Show all 24 lines
Use Case 1: Critical Services (Run Immediately)
Swift Copy
1 class MessageFetcherJob { 2 func start () { 3 // Wait for database before fetching messages 4 AppReadiness . runNowOrWhenAppDidBecomeReady { [weak self ] in 5 self ?. fetchMessages () 6 } 7 } 8 9 private func fetchMessages () { 10 // Database is ready - safe to query 11 let messages = database. fetchPendingMessages () 12 processMessages (messages) 13 } 14 }
Use Case 2: Non-Critical Services (Run Politely)
Swift Copy
1 class AnalyticsManager { 2 func start () { 3 // Not urgent - run politely to avoid launch stampede 4 AppReadiness . runNowOrWhenAppDidBecomeReadyAsync { [weak self ] in 5 self ?. uploadPendingEvents () 6 self ?. refreshConfiguration () 7 } 8 } 9 }
Use Case 3: UI-Dependent Services
Swift Copy
1 class TooltipManager { 2 func start () { 3 // Wait for UI before showing tooltips 4 AppReadiness . runNowOrWhenUIDidBecomeReady { [weak self ] in 5 self ?. showOnboardingTooltipIfNeeded () 6 } 7 } 8 }
Real Initialization Timeline :
Code Copy
1 App Launch 2 │ 3 ├─ 0ms: didFinishLaunching 4 │ ├─ Setup logging (10ms) 5 │ ├─ Setup crash reporting (20ms) 6 │ └─ Open database (50ms) 7 │ 8 ├─ 80ms: Run database migrations 9 │ └─ Migration complete (300ms) 10 │ 11 ├─ 380ms: AppReadiness . setAppIsReady () ← TRIGGERS QUEUED BLOCKS 12 │ ├─ MessageFetcherJob . start () (runs immediately) 13 │ ├─ ContactSyncJob . start () (runs immediately) 14 │ ├─ PushRegistration . start () (runs immediately) 15 │ │ 16 │ └─ Polite blocks (run with random delays): 17 │ ├─ 480ms (+100ms): AnalyticsManager . uploadEvents () 18 │ ├─ 730ms (+350ms): BackupCheck . checkIfNeeded () 19 │ └─ 1130ms (+750ms): CleanupJob . cleanOldData () 20 │ 21 ├─ 400ms: Setup root view controller 22 │ 23 ├─ 450ms: AppReadiness . setUIIsReady () ← TRIGGERS UI BLOCKS 24 │ ├─ TooltipManager . showTooltips () 25 │ └─ BadgeManager . updateBadge () 26 │ 27 └─ 500ms: User sees conversation list
Show all 27 lines
Why This Is Better#
Without coordination (stampede):
Swift Copy
1 // Everything runs at once - app freezes 2 class AppDelegate { 3 func application (...) -> Bool { 4 setupDatabase () // 300ms on main thread 5 fetchMessages () // Network call on main thread 6 syncContacts () // Heavy operation on main thread 7 uploadAnalytics () // More network on main thread 8 checkBackups () // Disk I/O on main thread 9 cleanupOldData () // Database queries on main thread 10 11 // Total: 2000ms+ of blocking work 12 // Result: Watchdog kills app on older devices 13 // Crash: 0x8badf00d (app took too long to launch) 14 } 15 }
With AppReadiness (coordinated):
Swift Copy
1 // Staged initialization - responsive app 2 class AppDelegate { 3 func application (...) -> Bool { 4 // Critical only (100ms) 5 setupDatabase () 6 7 AppReadiness . setAppIsReady () 8 9 // Queue non-critical work 10 AppReadiness . runNowOrWhenAppDidBecomeReadyAsync { 11 uploadAnalytics () // Runs at 500ms 12 } 13 14 AppReadiness . runNowOrWhenAppDidBecomeReadyAsync { 15 checkBackups () // Runs at 800ms 16 } 17 18 // Total blocking: 100ms 19 // Result: Fast launch, no crashes 20 } 21 }
Show all 21 lines
What You Can Learn#
Staged initialization prevents watchdog crashes : Don't do everything in didFinishLaunching"Polite" tasks spread CPU load : Random delays prevent simultaneous workReadiness levels coordinate dependencies : UI blocks wait for app blocksImmediate execution when already ready : No queueing if app already initializedSimple but effective : Just closures and flags, no complex state machine
The 0x8badf00d Crash#
What it is : iOS watchdog timer kills apps that take too long to respond to system events.
Limits :
Launch: ~20 seconds on old devices, ~5 seconds on new devices Resume from background: ~5 seconds Suspension: ~5 seconds
How AppReadiness prevents it :
Keeps initial launch under 1 second Defers non-critical work to after launch Spreads work over time instead of all at once
How to Apply This in Your App#
Basic Readiness System :
Swift Copy
1 class AppReadiness { 2 static let shared = AppReadiness () 3 4 private var isReady = false 5 private var readyBlocks: [() -> Void ] = [] 6 private var politeBlocks: [() -> Void ] = [] 7 8 func runWhenReady (_ block: @escaping () -> Void ) { 9 if isReady { 10 block () 11 } else { 12 readyBlocks. append (block) 13 } 14 } 15 16 func runWhenReadyAsync (_ block: @escaping () -> Void ) { 17 if isReady { 18 runPolitely (block) 19 } else { 20 politeBlocks. append (block) 21 } 22 } 23 24 func markReady () { 25 isReady = true 26 27 // Run queued blocks immediately 28 readyBlocks. forEach { $ 0 () } 29 readyBlocks = [] 30 31 // Run polite blocks with delay 32 politeBlocks. forEach { runPolitely ($ 0 ) } 33 politeBlocks = [] 34 } 35 36 private func runPolitely (_ block: @escaping () -> Void ) { 37 let delay = TimeInterval . random ( in : 0.1 ... 1.0 ) 38 DispatchQueue . main . asyncAfter (deadline: . now () + delay, execute: block) 39 } 40 } 41 42 // Usage in AppDelegate: 43 func application (...) -> Bool { 44 setupCriticalServices () // Fast, synchronous 45 46 AppReadiness . shared . markReady () 47 48 // Everything else 49 return true 50 } 51 52 // Usage in other classes: 53 class AnalyticsManager { 54 init () { 55 AppReadiness . shared . runWhenReadyAsync { 56 self . uploadEvents () // Runs after launch, with delay 57 } 58 } 59 }
Show all 59 lines
Advanced: Multiple Readiness Levels :
Swift Copy
1 class AppReadiness { 2 enum Level { 3 case database 4 case network 5 case ui 6 case fullyReady 7 } 8 9 private var currentLevel: Level = . database 10 private var callbacks: [ Level : [() -> Void ]] = [:] 11 12 func runWhen (level: Level , _ block: @escaping () -> Void ) { 13 if currentLevel >= level { 14 block () 15 } else { 16 callbacks[level, default: []]. append (block) 17 } 18 } 19 20 func markReady (level: Level ) { 21 currentLevel = level 22 23 // Run all callbacks for this level 24 callbacks[level]?. forEach { $ 0 () } 25 callbacks[level] = [] 26 } 27 } 28 29 // Usage: 30 AppReadiness . shared . runWhen (level: . database ) { 31 // Database is ready 32 } 33 34 AppReadiness . shared . runWhen (level: . ui ) { 35 // UI is ready 36 }
Show all 36 lines
Use cases:
Complex app initialization Apps with database migrations Apps with many third-party SDKs Apps experiencing watchdog crashes Apps with slow launch times
Conclusion & Key Takeaways#
Signal iOS demonstrates production-grade iOS development with patterns that solve real problems at scale. The most valuable lessons:
Adaptive throttling beats fixed timers Spread initialization over time to prevent watchdog crashes Context-aware caching prevents extension memory crashes
Concurrency#
Monotonic clocks for reliable timing Sendable atomics for Swift Concurrency Cross-process notifications for cache invalidation
Architecture#
Lazy dependency injection solves circular dependencies Multi-window management for complex UI coordination Protective overrides for UIKit quirks
Developer Experience#
Dual-mode debouncing for different UX scenarios Weak reference containers for protocol delegates Job queues with exponential backoff
Written based on analysis of Signal-iOS open source codebase. All code examples are simplified for clarity while maintaining the core concepts.