Skip to content
iOS Development

Innovative iOS Patterns from Signal’s Codebase

· 16 min read

/images/posts/signal-ios.pngsignal app image

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.

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
// Measure system load by monitoring display link performanceprivate func updateDisplayLinkFrequency(displayLinkDuration: TimeInterval) {    // If display link is firing slower than expected, system is under load    let actualFrequency = 1.0 / displayLinkDuration    // Use linear interpolation to determine target update interval    let displayLinkAlpha = recentDisplayLinkFrequency.inverseLerp(        lightDisplayLinkFrequency,  // 60 fps (system responsive)        heavyDisplayLinkFrequency,  // 20 fps (system struggling)        shouldClamp: true    )    // Slow down updates when system is struggling    let targetInterval = displayLinkAlpha.lerp(        fastUpdateInterval,  // 0.05s when responsive        slowUpdateInterval   // 0.5s when struggling    )    self.targetUpdateInterval = targetInterval}

How It's Used in Signal#

When you receive multiple messages in a conversation:

  1. Database changes are batched and queued
  2. Display link fires at screen refresh rate (60 fps on most devices)
  3. If system load increases (display link slows to 20-30 fps), Signal automatically reduces UI update frequency
  4. This prevents the dreaded "scroll stuttering" when messages are coming in

Why This Is Better#

Traditional approach:

Swift
// Updates immediately - causes jank with rapid changesNotificationCenter.default.addObserver { _ in    self.tableView.reloadData()}// Fixed timer - not adaptiveTimer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in    self.updateUI()}

Signal's approach:

Swift
// Adaptive throttling based on real device performance// Fast devices: ~50ms updates (responsive)// Slow devices under load: ~500ms updates (prevents jank)// Automatically adjusts based on actual display link fire rate

What You Can Learn#

  1. Use CADisplayLink as a load indicator: If it fires slower than 60fps, your system is under pressure
  2. Linear interpolation (lerp) is powerful: Smooth transitions between performance states
  3. Adaptive behavior beats fixed timers: Let the system tell you when it's struggling
  4. Batch UI updates to frame rate: Never update faster than the screen can display

How to Apply This in Your App#

Swift
class AdaptiveUpdateManager {    private var displayLink: CADisplayLink?    private var lastUpdateTime: CFTimeInterval = 0    private var targetUpdateInterval: TimeInterval = 0.05    func startMonitoring() {        displayLink = CADisplayLink(target: self, selector: #selector(displayLinkFired))        displayLink?.add(to: .main, forMode: .common)    }    @objc private func displayLinkFired(_ link: CADisplayLink) {        let duration = link.timestamp - lastUpdateTime        lastUpdateTime = link.timestamp        // Measure how long between display link fires        // If > 16.67ms (60fps), system is under load        let actualFPS = 1.0 / duration        // Adjust update frequency based on load        if actualFPS < 40 {            targetUpdateInterval = 0.5  // Slow down        } else if actualFPS > 55 {            targetUpdateInterval = 0.05  // Speed up        }        performPendingUpdatesIfNeeded()    }}

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
extension UIWindow.Level {    // Below everything - used for background blur    static let _background = UIWindow.Level(rawValue: -1)    // Normal app content    static let _normal = UIWindow.Level.normal    // Return-to-call banner (above normal UI)    static let _returnToCall = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 2)    // Active call view (above banner)    static let _callView = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 3)    // Screen blocking for privacy (above calls)    static let _screenBlocking = UIWindow.Level(rawValue: UIWindow.Level.alert.rawValue + 5)}class WindowManager {    private lazy var callViewWindow: UIWindow = {        let window = UIWindow()        window.windowLevel = ._callView        window.isHidden = false        window.backgroundColor = .clear        return window    }()    private lazy var returnToCallWindow: UIWindow = {        let window = UIWindow()        window.windowLevel = ._returnToCall        window.isHidden = false        window.backgroundColor = .clear        return window    }()    func startCall(call: SignalCall) {        let callVC = CallViewController(call: call)        callViewWindow.rootViewController = callVC        ensureWindowState()  // Bring to appropriate level    }    // Uses window level changes instead of hidden property    // to avoid UIKit layout bugs    private func ensureWindowState() {        if hasActiveCall {            callViewWindow.windowLevel = ._callView        } else {            callViewWindow.windowLevel = ._background  // Hide by lowering        }    }}

How It's Used in Signal#

Scenario 1: Starting a Call

  1. User initiates a call from a conversation
  2. WindowManager creates call UI in callViewWindow at level ._callView
  3. User can navigate anywhere in the app - call UI stays on top
  4. Tapping "return to call" switches to returnToCallWindow with a floating pip

Scenario 2: App Backgrounding

  1. User presses home button during sensitive conversation
  2. WindowManager immediately raises screenBlockingWindow to ._screenBlocking level
  3. Screen contents are hidden before app snapshot is taken
  4. Privacy preserved in app switcher

Scenario 3: Captcha Challenge

  1. Server requires captcha during registration
  2. Captcha UI presented in dedicated window at appropriate level
  3. Works regardless of current navigation state
  4. Can't be accidentally dismissed by user navigation

Why This Is Better#

Traditional approach:

Swift
// Presenting modally - can be dismissed, interferes with navigationpresent(callViewController, animated: true)// Adding subview to window - wrong z-order, rotation issuesUIApplication.shared.keyWindow?.addSubview(callView)

Signal's approach:

Swift
// Dedicated windows with precise z-ordering// Each window has its own view controller hierarchy// No interference between different UI concernscallViewWindow.windowLevel = ._callViewscreenBlockingWindow.windowLevel = ._screenBlocking

Advanced Technique: Private API Workarounds#

Signal even uses encoded selectors to work around iOS bugs:

Swift
// iOS has a rotation bug with certain window configurations// Access private API safely using encoded selector namesprivate func applyRotationWorkaround() {    let selectorData = Data(base64Encoded: "X3VwZGF0ZVRvSW50ZXJmYWNlT3JpZW50YXRpb246")!    let selectorString = String(data: selectorData, encoding: .utf8)!    let selector = NSSelectorFromString(selectorString)    if window.responds(to: selector) {        window.perform(selector, with: nil)    }}

This decodes to _updateToInterfaceOrientation: - a private method to fix rotation bugs.

What You Can Learn#

  1. Multiple windows solve layering problems: Better than view controller presentation for persistent overlays
  2. Window levels give precise z-ordering: No fighting with UIKit's presentation logic
  3. Lazy window initialization: Only create windows when needed
  4. Use level changes instead of hidden property: Avoids UIKit layout recalculation bugs
  5. Private API access can be done safely: Use encoding to avoid App Store rejection

How to Apply This in Your App#

Swift
class OverlayWindowManager {    // Main app window (set by system)    // Level: UIWindow.Level.normal    // Custom overlay window    private lazy var overlayWindow: UIWindow = {        let window = UIWindow(frame: UIScreen.main.bounds)        window.windowLevel = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 1)        window.backgroundColor = .clear        window.isHidden = false        return window    }()    func showOverlay(_ viewController: UIViewController) {        overlayWindow.rootViewController = viewController        overlayWindow.windowLevel = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 1)        overlayWindow.makeKey()  // Receives touch events    }    func hideOverlay() {        overlayWindow.windowLevel = UIWindow.Level(rawValue: -1)        // Don't set isHidden - can cause layout bugs    }}

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
public class DebouncedEvent {    public enum Mode {        /// Waits before firing - good for batching operations        /// Example: Search API calls while user types        case lastOnly        /// Fires immediately on first request, then throttles subsequent        /// Example: UI updates during scrolling (responsive but not excessive)        case firstLast    }    private let mode: Mode    private let maxFrequencySeconds: TimeInterval    private let queue: DispatchQueue    private var timer: Timer?    private var pendingRequest: (() -> Void)?    private var lastFireDate: Date?    public init(        mode: Mode,        maxFrequencySeconds: TimeInterval,        queue: DispatchQueue = .main    ) {        self.mode = mode        self.maxFrequencySeconds = maxFrequencySeconds        self.queue = queue    }    public func request(_ callback: @escaping () -> Void) {        pendingRequest = callback        switch mode {        case .lastOnly:            // Cancel existing timer, restart countdown            timer?.invalidate()            timer = Timer.scheduledTimer(                withTimeInterval: maxFrequencySeconds,                repeats: false            ) { [weak self] _ in                self?.fireIfNeeded()            }        case .firstLast:            // Fire immediately if enough time has passed            let now = Date()            let shouldFireImmediately: Bool            if let lastFireDate = lastFireDate {                let timeSinceLast = now.timeIntervalSince(lastFireDate)                shouldFireImmediately = timeSinceLast >= maxFrequencySeconds            } else {                shouldFireImmediately = true  // First request            }            if shouldFireImmediately {                fireIfNeeded()            } else {                // Schedule for later                timer?.invalidate()                timer = Timer.scheduledTimer(                    withTimeInterval: maxFrequencySeconds,                    repeats: false                ) { [weak self] _ in                    self?.fireIfNeeded()                }            }        }    }    private func fireIfNeeded() {        guard let pendingRequest = pendingRequest else { return }        lastFireDate = Date()        self.pendingRequest = nil        queue.async {            pendingRequest()        }    }}

How It's Used in Signal#

Use Case 1: Content Inset Updates (FirstLast mode)

Swift
class ConversationViewController: UIViewController {    // User is scrolling - we want immediate first update,    // then throttle subsequent updates to avoid jank    private lazy var contentInsetDebouncer = DebouncedEvent(        mode: .firstLast,  // ← Responsive!        maxFrequencySeconds: 0.05  // Max 20 updates/sec    )    func scrollViewDidScroll(_ scrollView: UIScrollView) {        contentInsetDebouncer.request { [weak self] in            self?.updateContentInsets()  // Expensive operation        }    }}

Result: First scroll update happens immediately (feels responsive), then throttled to prevent UI jank.

Use Case 2: Database Change Notifications (LastOnly mode)

Swift
class DatabaseChangeObserver {    // Cross-process database changes - we want to batch them    private lazy var crossProcessDebouncer = DebouncedEvent(        mode: .lastOnly,  // ← Batching!        maxFrequencySeconds: 0.2  // Wait 200ms of silence    )    func handleDarwinNotification() {        // Another process (NSE) changed database        crossProcessDebouncer.request { [weak self] in            self?.reloadFromDatabase()  // Expensive        }    }}

Result: If 10 changes happen in 150ms, only the last one triggers reload.

Why This Is Better#

Traditional debouncing (one mode):

Swift
// Only supports "last only" modeclass SimpleDebouncer {    func debounce(_ action: @escaping () -> Void) {        timer?.invalidate()        timer = Timer.scheduledTimer(...) { action() }    }}// Using it for scrolling feels laggy (waits before first update)debouncer.debounce { updateUI() }  // 300ms delay before first update

Signal's approach:

Swift
// Choose the right mode for the use caselet searchDebouncer = DebouncedEvent(mode: .lastOnly, maxFrequencySeconds: 0.3)let scrollDebouncer = DebouncedEvent(mode: .firstLast, maxFrequencySeconds: 0.05)// Searching: waits for user to stop typingsearchDebouncer.request { performSearch() }// Scrolling: immediate first update, then throttledscrollDebouncer.request { updateUI() }  // Fires immediately!

What You Can Learn#

  1. Different UX scenarios need different debouncing: One size doesn't fit all
  2. "FirstLast" mode provides responsive UX: No perceived delay on first action
  3. "LastOnly" mode is better for batching: Reduces unnecessary work
  4. Track last fire date for throttling: Simple but effective
  5. Provide 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
class SmartDebouncer {    enum Mode {        case waitForSilence  // Traditional debounce        case immediateAndThrottle  // Responsive throttle    }    private let mode: Mode    private let delay: TimeInterval    private var timer: Timer?    private var lastExecutionTime: Date?    func trigger(_ action: @escaping () -> Void) {        switch mode {        case .waitForSilence:            // Cancel and restart timer            timer?.invalidate()            timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in                action()            }        case .immediateAndThrottle:            let now = Date()            let shouldExecuteNow = lastExecutionTime == nil ||                                   now.timeIntervalSince(lastExecutionTime!) >= delay            if shouldExecuteNow {                lastExecutionTime = now                action()            } else {                // Schedule for later                timer?.invalidate()                timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in                    self?.lastExecutionTime = Date()                    action()                }            }        }    }}// Usagelet searchDebouncer = SmartDebouncer(mode: .waitForSilence, delay: 0.3)let scrollDebouncer = SmartDebouncer(mode: .immediateAndThrottle, delay: 0.05)

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
class Counter {    private let lock = NSLock()    private var _value: Int = 0    var value: Int {        lock.lock()        defer { lock.unlock() }        return _value    }}// Error: 'Counter' doesn't conform to 'Sendable'Task {    let counter = Counter()    await someAsyncFunction(counter)}

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
@propertyWrapperpublic struct Atomic<Value>: @unchecked Sendable {    private let lock = UnfairLock()    private var _value: Value    public init(wrappedValue: Value) {        self._value = wrappedValue    }    public var wrappedValue: Value {        get { lock.withLock { _value } }        set { lock.withLock { _value = newValue } }    }    // Read and modify atomically    public func update<T>(_ block: (inout Value) -> T) -> T {        lock.withLock {            block(&_value)        }    }    // State machine transitions    public func transition(from: Value, to: Value) throws where Value: Equatable {        try lock.withLock {            guard _value == from else {                throw AtomicError.transitionFailed            }            _value = to        }    }}// Specialized atomic typespublic final class AtomicBool: @unchecked Sendable {    private let lock = UnfairLock()    private var _value: Bool    public init(_ value: Bool) {        self._value = value    }    // Try to set flag atomically    public func tryToSetFlag() -> Bool {        lock.withLock {            guard !_value else { return false }            _value = true            return true        }    }    public func tryToClearFlag() -> Bool {        lock.withLock {            guard _value else { return false }            _value = false            return true        }    }}// Atomic collectionspublic final class AtomicArray<Element>: @unchecked Sendable {    private let lock = UnfairLock()    private var _value: [Element]    public func append(_ element: Element) {        lock.withLock {            _value.append(element)        }    }    public func removeAll() -> [Element] {        lock.withLock {            let old = _value            _value = []            return old        }    }}public final class AtomicDictionary<Key: Hashable, Value>: @unchecked Sendable {    private let lock = UnfairLock()    private var _value: [Key: Value]    public subscript(key: Key) -> Value? {        get { lock.withLock { _value[key] } }        set { lock.withLock { _value[key] = newValue } }    }}

How It's Used in Signal#

Use Case 1: State Machine with Transitions

Swift
public final class CancellableContinuation<T>: Sendable {    private enum State {        case initial        case waiting(CheckedContinuation<T, Error>)        case completed(Result<T, Error>)        case consumed    }    @Atomic private var state: State = .initial    public func waitForResult() async throws -> T {        // Use atomic transition to move from .initial → .waiting        try await withCheckedThrowingContinuation { continuation in            do {                try $state.transition(from: .initial, to: .waiting(continuation))            } catch {                // Already completed - resume immediately                if case .completed(let result) = state {                    continuation.resume(with: result)                }            }        }    }    public func resume(returning value: T) {        $state.update { state in            switch state {            case .waiting(let continuation):                continuation.resume(returning: value)                state = .consumed            case .initial:                state = .completed(.success(value))            default:                break  // Already consumed            }        }    }}

Use Case 2: Thread-Safe Flag

Swift
class MessageProcessor {    @Atomic private var isProcessing: Bool = false    func processMessages() async {        // Try to set flag - prevents concurrent processing        guard $isProcessing.tryToSetFlag() else {            return  // Already processing        }        defer {            _ = $isProcessing.tryToClearFlag()        }        // Process messages...    }}

Use Case 3: Atomic Counter

Swift
actor MessageSender {    @Atomic private var messagesSent: UInt = 0    func sendMessage(_ message: Message) async {        // Send message...        // Atomic increment        $messagesSent.update { $0 += 1 }    }    func getStats() -> UInt {        messagesSent  // Thread-safe read    }}

Why This Is Better#

Without atomics (race condition):

Swift
// Data race - multiple threads can read/write simultaneouslyclass UnsafeCounter: @unchecked Sendable {    var count = 0  // Not thread-safe!    func increment() {        count += 1  // Race condition    }}

With manual locking (verbose):

Swift
// Works but verbose and error-proneclass VerboseCounter: @unchecked Sendable {    private let lock = NSLock()    private var _count = 0    func increment() {        lock.lock()        _count += 1        lock.unlock()  // Easy to forget!    }}

Signal's approach:

Swift
// Clean, safe, and Sendableclass CleanCounter: Sendable {    @Atomic private var count = 0    func increment() {        $count.update { $0 += 1 }    }    func tryToSetFlag() -> Bool {        do {            try $count.transition(from: 0, to: 1)            return true        } catch {            return false        }    }}

What You Can Learn#

  1. Property wrappers make thread safety ergonomic: Clean syntax without sacrificing safety
  2. @unchecked Sendable is OK when you validate safety: As long as implementation is correct
  3. Specialized atomic types are better than generic: AtomicBool.tryToSetFlag() is clearer than generic update
  4. State transitions prevent invalid states: transition(from:to:) ensures valid state machine
  5. UnfairLock is faster than NSLock: For simple cases, unfair locks have less overhead

Advanced: UnfairLock Implementation#

Swift
// Signal's lock primitive (faster than NSLock)struct UnfairLock {    private var _lock = os_unfair_lock()    mutating func withLock<T>(_ block: () throws -> T) rethrows -> T {        os_unfair_lock_lock(&_lock)        defer { os_unfair_lock_unlock(&_lock) }        return try block()    }}

How to Apply This in Your App#

Start by copying Signal's atomic types into your project, or create simplified versions:

Swift
@propertyWrapperstruct Atomic<Value>: @unchecked Sendable {    private var lock = NSLock()    private var value: Value    init(wrappedValue: Value) {        self.value = wrappedValue    }    var wrappedValue: Value {        get {            lock.lock()            defer { lock.unlock() }            return value        }        set {            lock.lock()            defer { lock.unlock() }            value = newValue        }    }    var projectedValue: Atomic<Value> { self }    mutating func update<T>(_ block: (inout Value) -> T) -> T {        lock.lock()        defer { lock.unlock() }        return block(&value)    }}// Usageclass NetworkManager: Sendable {    @Atomic private var requestCount = 0    @Atomic private var activeRequests: Set<UUID> = []    func performRequest() async {        let requestId = UUID()        $activeRequests.update { $0.insert(requestId) }        $requestCount.update { $0 += 1 }        defer {            $activeRequests.update { $0.remove(requestId) }        }        // Perform request...    }}

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
let start = Date()// User adjusts clock backward 1 hourlet end = Date()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
/// A date/time value that is monotonically increasing and immune to system clock changes./// Perfect for measuring durations and timeouts within a single app session.public struct MonotonicDate: Comparable, Sendable, Codable {    /// System uptime in nanoseconds (never decreases)    private let uptimeNanos: UInt64    /// Creates a MonotonicDate representing "now"    public init() {        self.uptimeNanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)    }    /// Creates a MonotonicDate representing a future point in time    public init(fromNow interval: TimeInterval) {        let nowNanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)        let intervalNanos = UInt64(interval * 1_000_000_000)        self.uptimeNanos = nowNanos + intervalNanos    }    /// Time interval since another MonotonicDate    /// Always returns a sensible positive value (or zero)    public func timeIntervalSince(_ other: MonotonicDate) -> TimeInterval {        let deltaNanos = self.uptimeNanos - other.uptimeNanos        return TimeInterval(deltaNanos) / 1_000_000_000    }    /// Has this date/time passed yet?    public var isBeforeNow: Bool {        let nowNanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)        return uptimeNanos < nowNanos    }    /// Comparable conformance - allows sorting    public static func < (lhs: MonotonicDate, rhs: MonotonicDate) -> Bool {        lhs.uptimeNanos < rhs.uptimeNanos    }}

How It's Used in Signal#

Use Case 1: Call Duration Tracking

Swift
class IndividualCall {    private let callConnectedTime: MonotonicDate?    var callDuration: TimeInterval {        guard let connectedTime = callConnectedTime else {            return 0        }        return MonotonicDate().timeIntervalSince(connectedTime)    }    func markConnected() {        callConnectedTime = MonotonicDate()  // Record connection time    }}

Result: Call duration is always accurate, even if user adjusts their clock during the call.

Use Case 2: Message Send Timeout

Swift
class MessageSender {    func sendMessage(_ message: Message) async throws {        let deadline = MonotonicDate(fromNow: 30.0)  // 30-second timeout        while !deadline.isBeforeNow {            // Try to send...            if messageSent {                return            }            try await Task.sleep(nanoseconds: 1_000_000_000)  // 1 second        }        throw MessageSendError.timeout    }}

Use Case 3: Rate Limiting

Swift
class RateLimiter {    private var lastRequestTime: MonotonicDate?    private let minimumInterval: TimeInterval = 1.0    func canMakeRequest() -> Bool {        guard let lastTime = lastRequestTime else {            return true  // First request        }        let elapsed = MonotonicDate().timeIntervalSince(lastTime)        return elapsed >= minimumInterval    }    func recordRequest() {        lastRequestTime = MonotonicDate()    }}

Why This Is Better#

With Date (broken):

Swift
// Breaks when clock changesclass BrokenTimer {    let startTime = Date()    func checkTimeout() -> Bool {        let elapsed = Date().timeIntervalSince(startTime)        return elapsed > 30  // Can be negative! Can jump hours!    }}// Scenario:// 1. Start timer at 3:00 PM// 2. User sets clock back to 2:00 PM// 3. elapsed becomes -3600 seconds (negative!)

With MonotonicDate (reliable):

Swift
// Always increases, immune to clock changesclass ReliableTimer {    let startTime = MonotonicDate()    func checkTimeout() -> Bool {        let elapsed = MonotonicDate().timeIntervalSince(startTime)        return elapsed > 30  // Always positive, always accurate    }}

What You Can Learn#

  1. Never use Date() for durations: Use monotonic clock instead
  2. Monotonic clock is process-scoped: Doesn't persist across app launches (that's what Date is for)
  3. CLOCK_MONOTONIC_RAW is available on all platforms: Both iOS and macOS
  4. Nanosecond precision: More accurate than Date (which uses TimeInterval)
  5. 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 Signal's implementation or create simplified versionstruct MonotonicTime: Comparable {    private let nanos: UInt64    init() {        self.nanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)    }    static var now: MonotonicTime { MonotonicTime() }    func elapsed() -> TimeInterval {        let nowNanos = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)        return TimeInterval(nowNanos - nanos) / 1_000_000_000    }    static func < (lhs: MonotonicTime, rhs: MonotonicTime) -> Bool {        lhs.nanos < rhs.nanos    }}// Usage examplesclass Examples {    // Timeout    func withTimeout<T>(_ duration: TimeInterval, _ work: () async throws -> T) async throws -> T {        let start = MonotonicTime()        let result = try await work()        guard start.elapsed() < duration else {            throw TimeoutError()        }        return result    }    // Debouncing    class Debouncer {        var lastTriggerTime = MonotonicTime()        let interval: TimeInterval = 0.3        func shouldTrigger() -> Bool {            let elapsed = lastTriggerTime.elapsed()            if elapsed >= interval {                lastTriggerTime = MonotonicTime()                return true            }            return false        }    }    // Performance measurement    func measurePerformance<T>(_ label: String, _ work: () -> T) -> T {        let start = MonotonicTime()        let result = work()        let duration = start.elapsed()        print("\(label): \(duration * 1000)ms")        return result    }}

Replace all code that looks like this:

Swift
// Beforeclass OldCode {    var startTime: Date?    func startTimer() {        startTime = Date()    }    func checkElapsed() -> TimeInterval {        guard let start = startTime else { return 0 }        return Date().timeIntervalSince(start)  // BROKEN    }}

With this:

Swift
// Afterclass NewCode {    var startTime: MonotonicTime?    func startTimer() {        startTime = MonotonicTime()    }    func checkElapsed() -> TimeInterval {        guard let start = startTime else { return 0 }        return start.elapsed()  // RELIABLE    }}

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
/// Custom UICollectionView that protects against UIKit's temporary zero-frame assignments/// which can cause layout calculation crashes in conversation viewclass ConversationCollectionView: UICollectionView {    // MARK: - Frame Protection    /// Override frame setter to reject invalid values    /// UIKit sometimes temporarily sets frames to zero during complex transitions,    /// which causes our custom layout to crash when calculating cell sizes    override var frame: CGRect {        get {            super.frame        }        set {            let hasInvalidWidth = newValue.width <= 0 || newValue.width.isNaN            let hasInvalidHeight = newValue.height <= 0 || newValue.height.isNaN            if hasInvalidWidth || hasInvalidHeight {                // Log for debugging but don't crash                Logger.warn("Rejecting invalid frame: \(newValue)")                // Maintain current valid frame                return            }            // Only allow valid frames            super.frame = newValue        }    }    /// Also protect bounds (UIKit can set this independently)    override var bounds: CGRect {        get {            super.bounds        }        set {            let hasInvalidWidth = newValue.width <= 0 || newValue.width.isNaN            let hasInvalidHeight = newValue.height <= 0 || newValue.height.isNaN            if hasInvalidWidth || hasInvalidHeight {                Logger.warn("Rejecting invalid bounds: \(newValue)")                return            }            super.bounds = newValue        }    }    /// Invariant: Collection view should always have a reasonable size    /// This is critical for our custom layout which calculates cell widths as percentages    private func assertValidSize() {        assert(frame.width > 0 && frame.height > 0,               "ConversationCollectionView should always have valid size")    }}

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
class MessageCellLayout {    func calculateCellSize(for message: Message,                          collectionViewWidth: CGFloat) -> CGSize {        // Calculate width as percentage of collection view        let maxWidth = collectionViewWidth * 0.8  // 80% of screen        // If collectionViewWidth is 0, this crashes!        let textWidth = min(message.textWidth, maxWidth)        return CGSize(width: textWidth, height: message.height)    }}

Without Protection: During rotation or split view transitions:

  1. UIKit temporarily sets collectionView.frame = .zero
  2. Layout pass happens with collectionViewWidth = 0
  3. Cell size calculations produce invalid results
  4. Crash: "Invalid layout attributes"

With Protection:

  1. UIKit tries to set collectionView.frame = .zero
  2. Custom setter rejects it, maintains previous valid frame
  3. Layout calculations use valid width
  4. No crash, smooth transition

Real Bug This Prevents#

From Signal's git history, this prevented a crash that looked like:

Code
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException'*** Reason: 'Invalid layout attributes from -[UICollectionViewLayout layoutAttributesForItemAtIndexPath:]'Stack trace:- UICollectionView layoutSubviews- Custom layout calculating cell size with width=0- NaN propagation through layout calculations- Assertion failure

Why This Is Better#

Without protection (crashes):

Swift
// Crashes during complex transitionsclass NormalCollectionView: UICollectionView {    // Uses default frame setter    // UIKit can set frame = .zero temporarily    // Layout crashes}

With protection (stable):

Swift
// Maintains layout integrityclass ProtectedCollectionView: UICollectionView {    override var frame: CGRect {        get { super.frame }        set {            guard newValue.width > 0 && newValue.height > 0 else {                return  // Reject invalid frames            }            super.frame = newValue        }    }}

What You Can Learn#

  1. UIKit can set temporary invalid frames: Especially during transitions
  2. Override frame/bounds setters to protect: Valid pattern for complex views
  3. Log rejections for debugging: Helps identify problematic transitions
  4. Maintain invariants through overrides: Protect your view's contracts
  5. Check for both <= 0 and isNaN: Edge cases exist with floating point

Advanced: Why This Happens in UIKit#

UIKit's layout process sometimes involves:

Swift
// UIKit internally during complex transitions:func performComplexTransition() {    // 1. Save current frame    let savedFrame = view.frame    // 2. Temporarily set to zero for measurement    view.frame = .zero  //    // 3. Ask for intrinsic content size    let size = view.intrinsicContentSize    // 4. Calculate new frame    let newFrame = calculateFrame(from: size)    // 5. Set final frame    view.frame = newFrame    // Problem: If a layout pass happens during step 2-3,    // your view has zero size!}

Signal's protection prevents issues during steps 2-3.

How to Apply This in Your App#

Basic Protection:

Swift
class ProtectedCollectionView: UICollectionView {    override var frame: CGRect {        get { super.frame }        set {            // Reject zero or negative dimensions            guard newValue.width > 0, newValue.height > 0 else {                print("Rejecting invalid frame: \(newValue)")                return            }            super.frame = newValue        }    }    override var bounds: CGRect {        get { super.bounds }        set {            guard newValue.width > 0, newValue.height > 0 else {                print("Rejecting invalid bounds: \(newValue)")                return            }            super.bounds = newValue        }    }}

Advanced Protection (with NaN checks):

Swift
class RobustCollectionView: UICollectionView {    override var frame: CGRect {        get { super.frame }        set {            guard isValid(rect: newValue) else {                Logger.warn("Rejecting invalid frame: \(newValue)")                return            }            super.frame = newValue        }    }    private func isValid(rect: CGRect) -> Bool {        // Check for zero or negative        guard rect.width > 0, rect.height > 0 else {            return false        }        // Check for NaN (can happen with bad calculations)        guard !rect.width.isNaN, !rect.height.isNaN else {            return false        }        // Check for infinity (another edge case)        guard rect.width.isFinite, rect.height.isFinite else {            return false        }        return true    }}

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
class DebuggableCollectionView: UICollectionView {    override var frame: CGRect {        get { super.frame }        set {            let oldValue = super.frame            guard isValid(rect: newValue) else {                // Capture stack trace to find who's setting invalid frame                let stackTrace = Thread.callStackSymbols                Logger.error("Invalid frame rejected. Old: \(oldValue), New: \(newValue)\n\(stackTrace)")                return            }            super.frame = newValue            // Log significant changes for debugging            if abs(newValue.width - oldValue.width) > 100 {                Logger.debug("Large frame change: \(oldValue) → \(newValue)")            }        }    }}

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:

  1. Initialization order issues: Dependency A needs B, but B needs A (circular)
  2. Expensive initialization: Creating all dependencies upfront wastes resources
  3. Testing complexity: Mocking requires creating entire dependency graph
  4. Tight coupling: Changes to dependencies ripple through initializers

Example of the problem:

Swift
// Rigid initialization - all dependencies needed upfrontclass MessageProcessor {    private let database: Database    private let networkManager: NetworkManager    private let accountManager: AccountManager    init(database: Database,         networkManager: NetworkManager,         accountManager: AccountManager) {        self.database = database        self.networkManager = networkManager        self.accountManager = accountManager    }}// Creating this requires creating everything firstlet processor = MessageProcessor(    database: createDatabase(),  // Expensive    networkManager: createNetworkManager(),  // Expensive    accountManager: createAccountManager()  // Expensive)

Signal's Solution#

Signal uses closure-based lazy dependency injection where dependencies are provided as closures that return the dependency when called.

Swift
class DatabaseMigratorRunner {    // Dependencies are closures, not concrete instances    private let backgroundMessageFetcherFactory: () -> BackgroundMessageFetcherFactory    private let remoteConfigManager: () -> any RemoteConfigManager    private let tsAccountManager: () -> TSAccountManager    init(        backgroundMessageFetcherFactory: @escaping () -> BackgroundMessageFetcherFactory,        remoteConfigManager: @escaping () -> any RemoteConfigManager,        tsAccountManager: @escaping () -> TSAccountManager    ) {        self.backgroundMessageFetcherFactory = backgroundMessageFetcherFactory        self.remoteConfigManager = remoteConfigManager        self.tsAccountManager = tsAccountManager    }    func run() async throws {        // Dependencies are created only when needed        let remoteConfig = remoteConfigManager()        try await remoteConfig.refreshIfNeeded()        // This might not even be called in some code paths        if needsBackgroundFetch {            let fetcher = backgroundMessageFetcherFactory()            await fetcher.fetchMessages()        }        // Each call gets the current instance (could change between calls)        let account = tsAccountManager()        if account.isRegistered {            // ...        }    }}// Usage - pass closures instead of instanceslet runner = DatabaseMigratorRunner(    backgroundMessageFetcherFactory: { AppEnvironment.shared.backgroundMessageFetcherFactory },    remoteConfigManager: { SSKEnvironment.shared.remoteConfigManager },    tsAccountManager: { DependenciesBridge.shared.tsAccountManager })

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
// Database needs account info to decrypt// Account manager needs database to read settings// Network manager needs bothclass AppSetup {    func initializeServices() {        // Create database first (but don't use account yet)        let database = Database()        // Create account manager with lazy database access        let accountManager = AccountManager(            database: { database }  // Closure - can be called later        )        // Create network manager with lazy access to both        let networkManager = NetworkManager(            database: { database },            accountManager: { accountManager }        )        // Now they can all reference each other without circular init issues    }}

Use Case 2: Conditional Expensive Operations

Swift
class MessageProcessor {    // Expensive to create - only create if needed    private let attachmentDownloader: () -> AttachmentDownloader    func processMessage(_ message: Message) async {        if message.hasAttachments {            // Only create downloader when actually needed            let downloader = attachmentDownloader()            await downloader.downloadAttachments(for: message)        }        // If no attachments, downloader never created    }}

Use Case 3: Getting Current Instance (Mutable Singletons)

Some of Signal's services can be replaced (for testing or feature flags):

Swift
class FeatureFlag {    // The implementation can change at runtime    private let accountManager: () -> TSAccountManager    var isEnabled: Bool {        // Always get current instance        let manager = accountManager()        return manager.currentAccount != nil    }}// In tests, you can swap out the account managerfunc testFeature() {    let mockManager = MockAccountManager()    // Closure returns different instance for testing    let flag = FeatureFlag(        accountManager: { mockManager }    )    XCTAssertTrue(flag.isEnabled)}

Why This Is Better#

Traditional DI (inflexible):

Swift
// All dependencies created upfrontclass Traditional {    let heavy1: HeavyService    let heavy2: HeavyService    let heavy3: HeavyService    init(heavy1: HeavyService, heavy2: HeavyService, heavy3: HeavyService) {        self.heavy1 = heavy1  // Created even if never used        self.heavy2 = heavy2        self.heavy3 = heavy3    }}// Creating this is expensivelet obj = Traditional(    heavy1: HeavyService(),  // 100ms    heavy2: HeavyService(),  // 100ms    heavy3: HeavyService()   // 100ms - might not even be used!)

Signal's approach (flexible):

Swift
// Dependencies created only when neededclass Lazy {    let heavy1: () -> HeavyService    let heavy2: () -> HeavyService    let heavy3: () -> HeavyService    init(heavy1: @escaping () -> HeavyService,         heavy2: @escaping () -> HeavyService,         heavy3: @escaping () -> HeavyService) {        self.heavy1 = heavy1  // Just storing closure - instant        self.heavy2 = heavy2        self.heavy3 = heavy3    }    func doSomething() {        if condition {            heavy1().doWork()  // Created only if condition met        }    }}// Initialization is instantlet obj = Lazy(    heavy1: { HeavyService() },  // Not created yet    heavy2: { HeavyService() },    heavy3: { HeavyService() })

What You Can Learn#

  1. Closures enable lazy initialization: Don't create until needed
  2. Breaks circular dependencies: A can reference B, B can reference A
  3. Improves testability: Easy to inject different instances
  4. Reduces memory footprint: Only create what you use
  5. Allows runtime dependency changes: Closures can return different instances

Pattern Variations#

1. With Default Values:

Swift
class Service {    private let dependency: () -> Dependency    init(dependency: @escaping () -> Dependency = { DefaultDependency.shared }) {        self.dependency = dependency    }}

2. With Memoization:

Swift
class Service {    private let dependencyFactory: () -> Dependency    private lazy var dependency: Dependency = dependencyFactory()    init(dependency: @escaping () -> Dependency) {        self.dependencyFactory = dependency    }    func doWork() {        // Only created once, then cached        dependency.performAction()    }}

3. With Type-Erased Protocol:

Swift
class Service {    private let dependency: () -> any DependencyProtocol    init(dependency: @escaping () -> any DependencyProtocol) {        self.dependency = dependency    }}

How to Apply This in Your App#

Basic Pattern:

Swift
class MyViewController: UIViewController {    // Instead of: private let database: Database    private let database: () -> Database    // Instead of: private let networkManager: NetworkManager    private let networkManager: () -> NetworkManager    init(        database: @escaping () -> Database,        networkManager: @escaping () -> NetworkManager    ) {        self.database = database        self.networkManager = networkManager        super.init(nibName: nil, bundle: nil)    }    func loadData() async {        // Create when needed        let db = database()        let data = await db.fetch()        // Send to server        let network = networkManager()        try await network.upload(data)    }}// Usagelet vc = MyViewController(    database: { AppDelegate.shared.database },    networkManager: { AppDelegate.shared.networkManager })

Advanced: Solving Circular Dependencies:

Swift
class ServiceA {    private let serviceB: () -> ServiceB    init(serviceB: @escaping () -> ServiceB) {        self.serviceB = serviceB    }    func doSomething() {        serviceB().helperMethod()    }}class ServiceB {    private let serviceA: () -> ServiceA    init(serviceA: @escaping () -> ServiceA) {        self.serviceA = serviceA    }    func doSomethingElse() {        serviceA().helperMethod()    }}// No circular init issue!var serviceA: ServiceA!var serviceB: ServiceB!serviceA = ServiceA(serviceB: { serviceB })serviceB = ServiceB(serviceA: { serviceA })

Testing:

Swift
class MyViewControllerTests: XCTestCase {    func testDataLoading() async {        let mockDatabase = MockDatabase()        let mockNetwork = MockNetworkManager()        let vc = MyViewController(            database: { mockDatabase },            networkManager: { mockNetwork }        )        await vc.loadData()        XCTAssertTrue(mockDatabase.fetchCalled)        XCTAssertTrue(mockNetwork.uploadCalled)    }}

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 ~24MB
  • Share Extension: Limited to ~120MB
  • Widgets: 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
/// LRU Cache that adjusts size based on app context/// Main app gets large cache, NSE gets smaller cachepublic class LRUCache<KeyType: Hashable & Equatable, ValueType> {    private let cache: NSCache<AnyObject, AnyObject>    private let maxSize: Int    private let nseMaxSize: Int    public init(        maxSize: Int,        nseMaxSize: Int? = nil,        shouldEvacuateInBackground: Bool = true    ) {        self.maxSize = maxSize        self.nseMaxSize = nseMaxSize ?? max(4, maxSize / 8)  // Default: 1/8th size        self.cache = NSCache()        self.cache.countLimit = CurrentAppContext().isNSE ? self.nseMaxSize : self.maxSize        // Clear cache when app backgrounds to free memory        if shouldEvacuateInBackground {            NotificationCenter.default.addObserver(                forName: .OWSApplicationDidEnterBackground,                object: nil,                queue: nil            ) { [weak self] _ in                self?.removeAllObjects()            }        }    }    public func get(key: KeyType) -> ValueType? {        guard let value = cache.object(forKey: key as AnyObject) as? ValueType else {            return nil        }        return value    }    public func set(key: KeyType, value: ValueType) {        cache.setObject(value as AnyObject, forKey: key as AnyObject)    }    public func removeAllObjects() {        cache.removeAllObjects()    }}// CurrentAppContext determines execution environmentprotocol AppContext {    var isMainApp: Bool { get }    var isNSE: Bool { get }    var isShareExtension: Bool { get }    var reportedMemoryWarnings: Bool { get }}

How It's Used in Signal#

Use Case 1: Profile Image Cache

Swift
class ProfileImageCache {    // Main app: Cache 256 profile images (~50MB)    // NSE: Cache 32 profile images (~6MB)    private let imageCache = LRUCache<String, UIImage>(        maxSize: 256,        nseMaxSize: 32    )    func profileImage(for address: SignalServiceAddress) -> UIImage? {        let key = address.stringValue        if let cached = imageCache.get(key: key) {            return cached  // Cache hit        }        // Load from disk        guard let image = loadFromDisk(address) else {            return nil        }        // Cache for next time (automatically size-aware)        imageCache.set(key: key, value: image)        return image    }}

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
class ThreadModelCache {    // Main app: Cache 64 thread models    // NSE: Cache 8 thread models    private let cache = LRUCache<String, TSThread>(        maxSize: 64,        nseMaxSize: 8    )    func thread(for uniqueId: String, tx: DBReadTransaction) -> TSThread? {        if let cached = cache.get(key: uniqueId) {            return cached        }        guard let thread = TSThread.anyFetch(uniqueId: uniqueId, transaction: tx) else {            return nil        }        cache.set(key: uniqueId, value: thread)        return thread    }}

Use Case 3: Attachment Thumbnail Cache

Swift
class ThumbnailCache {    // Main app: Cache 512 thumbnails (~100MB)    // NSE: Cache 16 thumbnails (~3MB)    // Share extension: Cache 64 thumbnails (~12MB)    private let cache: LRUCache<String, UIImage>    init() {        let nseSize = 16        let shareExtensionSize = 64        let mainAppSize = 512        let maxSize: Int        if CurrentAppContext().isNSE {            maxSize = nseSize        } else if CurrentAppContext().isShareExtension {            maxSize = shareExtensionSize        } else {            maxSize = mainAppSize        }        self.cache = LRUCache(maxSize: maxSize, nseMaxSize: nseSize)    }}

Why This Is Better#

Without context awareness (crashes in NSE):

Swift
// Same cache size everywhereclass NaiveCache {    private let cache = NSCache<NSString, UIImage>()    init() {        cache.countLimit = 500  // Too large for NSE!    }}// In NSE processing notification:// - Loads 500 profile images// - Uses 100MB+ of memory// - NSE limit is ~24MB// - Crash: "Extension terminated due to memory pressure"

With context awareness (stable):

Swift
// Adapts to environmentclass SmartCache {    private let cache = NSCache<NSString, UIImage>()    init() {        if CurrentAppContext().isNSE {            cache.countLimit = 32  // Small for NSE        } else {            cache.countLimit = 500  // Large for main app        }    }}

Advanced: Memory Pressure Response#

Signal also evacuates caches when memory warnings occur:

Swift
class LRUCache<K: Hashable, V> {    init(maxSize: Int, shouldEvacuateInBackground: Bool = true) {        // ...        // Clear cache on memory warning        NotificationCenter.default.addObserver(            forName: UIApplication.didReceiveMemoryWarningNotification,            object: nil,            queue: nil        ) { [weak self] _ in            Logger.warn("Memory warning - evacuating cache")            self?.removeAllObjects()        }        // Clear cache when backgrounding (free memory)        if shouldEvacuateInBackground {            NotificationCenter.default.addObserver(                forName: .OWSApplicationDidEnterBackground,                object: nil,                queue: nil            ) { [weak self] _ in                self?.removeAllObjects()            }        }    }}

What You Can Learn#

  1. Extensions have strict memory limits: NSE ~24MB, Share ~120MB
  2. NSCache is better than Dictionary: Automatic eviction under memory pressure
  3. Context detection is essential: Don't treat all processes equally
  4. Background evacuation helps: Clear caches when app backgrounds
  5. Memory 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
import Foundationclass ContextAwareCache<Key: Hashable, Value> {    private let cache = NSCache<AnyHashable, AnyObject>()    init(mainAppLimit: Int, extensionLimit: Int) {        // Detect if running in extension        let isExtension = Bundle.main.bundlePath.hasSuffix(".appex")        cache.countLimit = isExtension ? extensionLimit : mainAppLimit        // Clear on memory warning        NotificationCenter.default.addObserver(            forName: UIApplication.didReceiveMemoryWarningNotification,            object: nil,            queue: nil        ) { [weak cache] _ in            cache?.removeAllObjects()        }    }    func get(_ key: Key) -> Value? {        cache.object(forKey: key as AnyHashable) as? Value    }    func set(_ key: Key, value: Value) {        cache.setObject(value as AnyObject, forKey: key as AnyHashable)    }    func clear() {        cache.removeAllObjects()    }}// Usagelet imageCache = ContextAwareCache<String, UIImage>(    mainAppLimit: 500,    extensionLimit: 32)

Advanced: Memory Cost Aware:

Swift
class SizeAwareCache<Key: Hashable, Value> {    private let cache = NSCache<AnyHashable, AnyObject>()    init(mainAppMemoryMB: Int, extensionMemoryMB: Int) {        let isExtension = Bundle.main.bundlePath.hasSuffix(".appex")        let memoryLimit = isExtension ? extensionMemoryMB : mainAppMemoryMB        cache.totalCostLimit = memoryLimit * 1024 * 1024  // Convert to bytes    }    func set(_ key: Key, value: Value, costInBytes: Int) {        cache.setObject(value as AnyObject, forKey: key as AnyHashable, cost: costInBytes)    }}// Usagelet imageCache = SizeAwareCache<String, UIImage>(    mainAppMemoryMB: 50,  // 50MB for main app    extensionMemoryMB: 5   // 5MB for extension)// Store with actual memory costlet image = UIImage(...)let imageSize = image.pngData()?.count ?? 0imageCache.set("key", value: image, costInBytes: imageSize)

Production-Ready with Logging:

Swift
class ProductionCache<Key: Hashable, Value> {    private let cache = NSCache<AnyHashable, AnyObject>()    private let name: String    init(name: String, mainAppLimit: Int, extensionLimit: Int) {        self.name = name        let isExtension = Bundle.main.bundlePath.hasSuffix(".appex")        let limit = isExtension ? extensionLimit : mainAppLimit        cache.countLimit = limit        cache.delegate = self        print("[\(name)] Initialized with limit: \(limit) (\(isExtension ? "extension" : "main app"))")    }}extension ProductionCache: NSCacheDelegate {    func cache(_ cache: NSCache<AnyHashable, AnyObject>, willEvictObject obj: Any) {        print("[\(name)] Evicting object due to memory pressure")    }}

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 app
  • Notification Service Extension (NSE): Processing incoming message notifications
  • Share 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 only
  • Database 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
/// Read cache that automatically invalidates when another process modifies the databaseclass ModelReadCache<KeyType: Hashable, ValueType> {    private let cache: LRUCache<KeyType, ValueType>    private let cacheIdentifier: String    // Darwin notification name (system-wide)    private var darwinNotificationName: String {        "org.signal.database.didChange.\(cacheIdentifier)"    }    init(cacheIdentifier: String, maxSize: Int, nseMaxSize: Int) {        self.cacheIdentifier = cacheIdentifier        self.cache = LRUCache(maxSize: maxSize, nseMaxSize: nseMaxSize)        // Register for cross-process database change notifications        registerForCrossProcessNotifications()        // Also register for in-process notifications (faster)        NotificationCenter.default.addObserver(            self,            selector: #selector(handleInProcessDatabaseChange),            name: .DatabaseDidChange,            object: nil        )    }    private func registerForCrossProcessNotifications() {        // Darwin notifications work across process boundaries        let center = CFNotificationCenterGetDarwinNotifyCenter()        CFNotificationCenterAddObserver(            center,            Unmanaged.passUnretained(self).toOpaque(),            { (center, observer, name, object, userInfo) in                guard let observer = observer else { return }                let cache = Unmanaged<ModelReadCache>.fromOpaque(observer).takeUnretainedValue()                cache.handleCrossProcessDatabaseChange()            },            darwinNotificationName as CFString,            nil,            .deliverImmediately        )    }    @objc private func handleInProcessDatabaseChange() {        // Same process modified database - invalidate immediately        cache.removeAllObjects()    }    private func handleCrossProcessDatabaseChange() {        // Another process modified database - invalidate cache        DispatchQueue.main.async {            self.cache.removeAllObjects()            // Notify UI to refresh            NotificationCenter.default.post(name: .ModelCacheDidInvalidate, object: nil)        }    }    // Read from cache or database    func read(key: KeyType, transaction: DBReadTransaction) -> ValueType? {        // Check cache first        if let cached = cache.get(key: key) {            return cached        }        // Cache miss - read from database        guard let value = readFromDatabase(key: key, transaction: transaction) else {            return nil        }        // Populate cache for next read        cache.set(key: key, value: value)        return value    }}// Write path posts Darwin notificationclass Database {    func write(_ block: (DBWriteTransaction) -> Void) {        performWrite(block)        // Notify all processes that database changed        postCrossProcessDatabaseChangeNotification()    }    private func postCrossProcessDatabaseChangeNotification() {        let center = CFNotificationCenterGetDarwinNotifyCenter()        let notificationName = "org.signal.database.didChange" as CFString        CFNotificationCenterPostNotification(            center,            CFNotificationName(notificationName),            nil,            nil,            true  // Deliver immediately        )    }}

How It's Used in Signal#

Scenario: Message Arrives While App is in Background

  1. NSE Process receives push notification
  2. NSE decrypts message and writes to database
  3. NSE posts Darwin notification
  4. Main App (suspended) receives Darwin notification
  5. Main App invalidates thread list cache
  6. User opens app → Main App reads fresh data from database
  7. New message appears immediately
Swift
class ThreadModelCache {    private let cache: ModelReadCache<String, TSThread>    init() {        // Main app: cache 64 threads        // NSE: cache 8 threads        self.cache = ModelReadCache(            cacheIdentifier: "threads",            maxSize: 64,            nseMaxSize: 8        )    }    func getThread(uniqueId: String, transaction: DBReadTransaction) -> TSThread? {        // Automatically uses cache if valid        // Automatically invalidates if another process wrote to DB        return cache.read(key: uniqueId, transaction: transaction)    }}
Swift
class SignalRecipientCache {    private let cache: ModelReadCache<SignalServiceAddress, SignalRecipient>    init() {        self.cache = ModelReadCache(            cacheIdentifier: "recipients",            maxSize: 256,            nseMaxSize: 32        )    }    func recipient(for address: SignalServiceAddress, tx: DBReadTransaction) -> SignalRecipient? {        cache.read(key: address, transaction: tx)    }}

Real-World Flow Diagram#

Code
┌─────────────────────────────────────────────────────────────────┐│  Time: 10:00:00 AM - User viewing conversation list (Main App)  │└─────────────────────────────────────────────────────────────────┘                           │                           │ Main App caches thread list in memory                           ▼┌─────────────────────────────────────────────────────────────────┐│  Time: 10:00:05 AM - User presses home button (App backgrounds) │└─────────────────────────────────────────────────────────────────┘                           │                           ▼┌─────────────────────────────────────────────────────────────────┐│  Time: 10:00:10 AM - New message arrives (NSE wakes up)         ││  NSE Process:                                                   ││  1. Decrypt message                                             ││  2. Write to database: INSERT INTO messages ...                 ││  3. Update thread: UPDATE threads SET lastMessageDate = ...     ││  4. Post Darwin notification: "org.signal.database.didChange"   │└─────────────────────────────────────────────────────────────────┘                           │                           │ Darwin notification crosses process boundary                           ▼┌─────────────────────────────────────────────────────────────────┐│  Time: 10:00:10 AM - Main App receives Darwin notification      ││  Main App (suspended):                                          ││  1. Darwin notification handler fires                           ││  2. Invalidate thread cache                                     ││  3. Invalidate message cache                                    │└─────────────────────────────────────────────────────────────────┘                           │                           ▼┌─────────────────────────────────────────────────────────────────┐│  Time: 10:00:15 AM - User opens app again                       ││  Main App:                                                      ││  1. Render conversation list                                    ││  2. Check thread cache → MISS (was invalidated)                 ││  3. Read from database → Gets fresh data with new message       ││  4. Display shows new message immediately ✓                     │└─────────────────────────────────────────────────────────────────┘

Why This Is Better#

Without cross-process invalidation (stale data):

Swift
// Main app caches become stale when NSE writesclass NaiveThreadCache {    private var cache: [String: TSThread] = [:]    func getThread(_ id: String) -> TSThread? {        if let cached = cache[id] {            return cached  // STALE! NSE modified database but cache doesn't know        }        let thread = database.fetch(id)        cache[id] = thread        return thread    }}// User experience:// - Message arrives (NSE processes it)// - User opens app// - Conversation list doesn't show new message (stale cache)// - User has to force refresh or wait for cache timeout

With cross-process invalidation (always fresh):

Swift
// Cache automatically invalidates when ANY process writesclass SmartThreadCache {    private let cache: ModelReadCache<String, TSThread>    func getThread(_ id: String, tx: DBReadTransaction) -> TSThread? {        // Cache automatically invalidated by NSE write        return cache.read(key: id, transaction: tx)    }}// User experience:// - Message arrives (NSE processes it)// - NSE posts Darwin notification// - Main app cache invalidates// - User opens app// - Conversation list immediately shows new message ✓

What You Can Learn#

  1. Darwin notifications are cross-process: Work between app and extensions
  2. CFNotificationCenter != NotificationCenter: Different APIs, different scopes
  3. Invalidation is cheaper than polling: Don't poll database for changes
  4. Cache per-model-type: Separate caches for threads, messages, contacts
  5. Combine 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
class CrossProcessNotifier {    static let shared = CrossProcessNotifier()    // Post notification (any process can call this)    func postDatabaseChanged() {        let center = CFNotificationCenterGetDarwinNotifyCenter()        let name = "com.yourapp.database.changed" as CFString        CFNotificationCenterPostNotification(            center,            CFNotificationName(name),            nil,            nil,            true  // deliverImmediately        )    }    // Observe notification (any process can observe)    func observeDatabaseChanges(callback: @escaping () -> Void) {        let center = CFNotificationCenterGetDarwinNotifyCenter()        let name = "com.yourapp.database.changed" as CFString        // Create observer context        class ObserverContext {            let callback: () -> Void            init(callback: @escaping () -> Void) {                self.callback = callback            }        }        let context = ObserverContext(callback: callback)        let observer = Unmanaged.passRetained(context).toOpaque()        CFNotificationCenterAddObserver(            center,            observer,            { (center, observer, name, object, userInfo) in                guard let observer = observer else { return }                let context = Unmanaged<ObserverContext>.fromOpaque(observer).takeUnretainedValue()                DispatchQueue.main.async {                    context.callback()                }            },            name,            nil,            .deliverImmediately        )    }}// Usage in Main App:class MainAppCache {    init() {        CrossProcessNotifier.shared.observeDatabaseChanges { [weak self] in            print("Database changed by another process!")            self?.clearCache()        }    }}// Usage in NSE:class NotificationService: UNNotificationServiceExtension {    override func didReceive(_ request: UNNotificationRequest) {        // Process message...        database.insert(message)        // Notify main app        CrossProcessNotifier.shared.postDatabaseChanged()    }}

Production-Ready Cache with Invalidation:

Swift
class CrossProcessCache<Key: Hashable, Value> {    private let cache = NSCache<AnyHashable, AnyObject>()    private let darwinNotificationName: String    init(identifier: String, maxSize: Int) {        self.darwinNotificationName = "com.yourapp.cache.\(identifier)"        cache.countLimit = maxSize        // Register for invalidation notifications        registerForInvalidation()    }    private func registerForInvalidation() {        let center = CFNotificationCenterGetDarwinNotifyCenter()        let name = darwinNotificationName as CFString        CFNotificationCenterAddObserver(            center,            Unmanaged.passUnretained(self).toOpaque(),            { (center, observer, name, object, userInfo) in                guard let observer = observer else { return }                let cache = Unmanaged<CrossProcessCache>.fromOpaque(observer).takeUnretainedValue()                DispatchQueue.main.async {                    print("[\(cache.darwinNotificationName)] Invalidating due to cross-process change")                    cache.cache.removeAllObjects()                }            },            name,            nil,            .deliverImmediately        )    }    func get(_ key: Key) -> Value? {        cache.object(forKey: key as AnyHashable) as? Value    }    func set(_ key: Key, value: Value) {        cache.setObject(value as AnyObject, forKey: key as AnyHashable)    }    func invalidateAllProcesses() {        // Clear local cache        cache.removeAllObjects()        // Notify other processes        let center = CFNotificationCenterGetDarwinNotifyCenter()        let name = darwinNotificationName as CFString        CFNotificationCenterPostNotification(center, CFNotificationName(name), nil, nil, true)    }    deinit {        let center = CFNotificationCenterGetDarwinNotifyCenter()        CFNotificationCenterRemoveEveryObserver(center, Unmanaged.passUnretained(self).toOpaque())    }}// Usage:let threadCache = CrossProcessCache<String, Thread>(identifier: "threads", maxSize: 100)// In NSE after writing message:database.write { tx in    tx.insert(message)}threadCache.invalidateAllProcesses()  // Invalidates main app's cache too!

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 screen
  • Watchdog timer kills app → 0x8badf00d crash on older devices
  • Poor user experience → App feels unresponsive
  • Memory spikes → Simultaneous initialization causes pressure

Signal's Solution#

Signal uses an AppReadiness coordination system that:

  1. Defines multiple readiness levels
  2. Allows tasks to register for specific readiness stages
  3. Spreads initialization over time ("polite" async tasks)
  4. Prevents the dreaded "stampede" of simultaneous work
Swift
/// Coordinates app initialization to prevent launch stampede@objcpublic class AppReadiness: NSObject {    // MARK: - Readiness Levels    /// App is ready - database is accessible    private static var isAppReady = false    /// UI is ready - safe to present view controllers    private static var isUIReady = false    /// Registered blocks waiting for readiness    private static var appReadyBlocks: [() -> Void] = []    private static var uiReadyBlocks: [() -> Void] = []    private static var politeAsyncBlocks: [() -> Void] = []    // MARK: - Registration    /// Run block when app becomes ready (or immediately if already ready)    @objc    public class func runNowOrWhenAppDidBecomeReady(_ block: @escaping () -> Void) {        guard !isAppReady else {            // Already ready - run immediately            block()            return        }        // Not ready yet - queue for later        appReadyBlocks.append(block)    }    /// Run block when UI becomes ready (or immediately if already ready)    @objc    public class func runNowOrWhenUIDidBecomeReady(_ block: @escaping () -> Void) {        guard !isUIReady else {            block()            return        }        uiReadyBlocks.append(block)    }    /// Run block asynchronously after app is ready, with delay to be "polite"    /// Use this for non-critical initialization to spread CPU load    @objc    public class func runNowOrWhenAppDidBecomeReadyAsync(_ block: @escaping () -> Void) {        guard !isAppReady else {            // Already ready - run politely (with delay)            runPolitely(block)            return        }        // Not ready yet - queue for polite execution later        politeAsyncBlocks.append(block)    }    // MARK: - Readiness Signaling    /// Mark app as ready - triggers queued blocks    @objc    public class func setAppIsReady() {        assert(!isAppReady, "App is already ready")        isAppReady = true        // Run all queued app-ready blocks        let blocks = appReadyBlocks        appReadyBlocks = []        for block in blocks {            block()        }        // Run polite blocks with delay to spread load        let politeBlocks = politeAsyncBlocks        politeAsyncBlocks = []        for block in politeBlocks {            runPolitely(block)        }    }    /// Mark UI as ready - triggers UI-related blocks    @objc    public class func setUIIsReady() {        assert(isAppReady, "UI ready called before app ready")        assert(!isUIReady, "UI is already ready")        isUIReady = true        let blocks = uiReadyBlocks        uiReadyBlocks = []        for block in blocks {            block()        }    }    // MARK: - Polite Execution    /// Run block asynchronously with delay to be "polite" (spread CPU load)    private class func runPolitely(_ block: @escaping () -> Void) {        // Random delay between 0.1-1.0 seconds        // Spreads initialization over time instead of all at once        let delay = TimeInterval.random(in: 0.1...1.0)        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {            block()        }    }}

How It's Used in Signal#

Initialization Sequence

Swift
class AppDelegate: UIResponder, UIApplicationDelegate {    func application(_ application: UIApplication,                    didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {        // Phase 1: Critical setup (blocks UI)        setupLogging()        setupCrashReporting()        // Phase 2: Database (required for everything else)        setupDatabase()        runDatabaseMigrations()        // Phase 3: Mark app ready        AppReadiness.setAppIsReady()  // ← Triggers queued blocks        // Phase 4: UI setup        setupRootViewController()        // Phase 5: Mark UI ready        AppReadiness.setUIIsReady()  // ← Triggers UI blocks        return true    }}

Use Case 1: Critical Services (Run Immediately)

Swift
class MessageFetcherJob {    func start() {        // Wait for database before fetching messages        AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in            self?.fetchMessages()        }    }    private func fetchMessages() {        // Database is ready - safe to query        let messages = database.fetchPendingMessages()        processMessages(messages)    }}

Use Case 2: Non-Critical Services (Run Politely)

Swift
class AnalyticsManager {    func start() {        // Not urgent - run politely to avoid launch stampede        AppReadiness.runNowOrWhenAppDidBecomeReadyAsync { [weak self] in            self?.uploadPendingEvents()            self?.refreshConfiguration()        }    }}

Use Case 3: UI-Dependent Services

Swift
class TooltipManager {    func start() {        // Wait for UI before showing tooltips        AppReadiness.runNowOrWhenUIDidBecomeReady { [weak self] in            self?.showOnboardingTooltipIfNeeded()        }    }}

Real Initialization Timeline:

Code
App Launch│├─ 0ms: didFinishLaunching│  ├─ Setup logging (10ms)│  ├─ Setup crash reporting (20ms)│  └─ Open database (50ms)│├─ 80ms: Run database migrations│  └─ Migration complete (300ms)│├─ 380ms: AppReadiness.setAppIsReady() ← TRIGGERS QUEUED BLOCKS│  ├─ MessageFetcherJob.start() (runs immediately)│  ├─ ContactSyncJob.start() (runs immediately)│  ├─ PushRegistration.start() (runs immediately)│  ││  └─ Polite blocks (run with random delays):│     ├─ 480ms (+100ms): AnalyticsManager.uploadEvents()│     ├─ 730ms (+350ms): BackupCheck.checkIfNeeded()│     └─ 1130ms (+750ms): CleanupJob.cleanOldData()│├─ 400ms: Setup root view controller│├─ 450ms: AppReadiness.setUIIsReady() ← TRIGGERS UI BLOCKS│  ├─ TooltipManager.showTooltips()│  └─ BadgeManager.updateBadge()│└─ 500ms: User sees conversation list

Why This Is Better#

Without coordination (stampede):

Swift
// Everything runs at once - app freezesclass AppDelegate {    func application(...) -> Bool {        setupDatabase()  // 300ms on main thread        fetchMessages()  // Network call on main thread        syncContacts()   // Heavy operation on main thread        uploadAnalytics()  // More network on main thread        checkBackups()   // Disk I/O on main thread        cleanupOldData()  // Database queries on main thread        // Total: 2000ms+ of blocking work        // Result: Watchdog kills app on older devices        // Crash: 0x8badf00d (app took too long to launch)    }}

With AppReadiness (coordinated):

Swift
// Staged initialization - responsive appclass AppDelegate {    func application(...) -> Bool {        // Critical only (100ms)        setupDatabase()        AppReadiness.setAppIsReady()        // Queue non-critical work        AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {            uploadAnalytics()  // Runs at 500ms        }        AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {            checkBackups()  // Runs at 800ms        }        // Total blocking: 100ms        // Result: Fast launch, no crashes    }}

What You Can Learn#

  1. Staged initialization prevents watchdog crashes: Don't do everything in didFinishLaunching
  2. "Polite" tasks spread CPU load: Random delays prevent simultaneous work
  3. Readiness levels coordinate dependencies: UI blocks wait for app blocks
  4. Immediate execution when already ready: No queueing if app already initialized
  5. Simple 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
class AppReadiness {    static let shared = AppReadiness()    private var isReady = false    private var readyBlocks: [() -> Void] = []    private var politeBlocks: [() -> Void] = []    func runWhenReady(_ block: @escaping () -> Void) {        if isReady {            block()        } else {            readyBlocks.append(block)        }    }    func runWhenReadyAsync(_ block: @escaping () -> Void) {        if isReady {            runPolitely(block)        } else {            politeBlocks.append(block)        }    }    func markReady() {        isReady = true        // Run queued blocks immediately        readyBlocks.forEach { $0() }        readyBlocks = []        // Run polite blocks with delay        politeBlocks.forEach { runPolitely($0) }        politeBlocks = []    }    private func runPolitely(_ block: @escaping () -> Void) {        let delay = TimeInterval.random(in: 0.1...1.0)        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: block)    }}// Usage in AppDelegate:func application(...) -> Bool {    setupCriticalServices()  // Fast, synchronous    AppReadiness.shared.markReady()    // Everything else    return true}// Usage in other classes:class AnalyticsManager {    init() {        AppReadiness.shared.runWhenReadyAsync {            self.uploadEvents()  // Runs after launch, with delay        }    }}

Advanced: Multiple Readiness Levels:

Swift
class AppReadiness {    enum Level {        case database        case network        case ui        case fullyReady    }    private var currentLevel: Level = .database    private var callbacks: [Level: [() -> Void]] = [:]    func runWhen(level: Level, _ block: @escaping () -> Void) {        if currentLevel >= level {            block()        } else {            callbacks[level, default: []].append(block)        }    }    func markReady(level: Level) {        currentLevel = level        // Run all callbacks for this level        callbacks[level]?.forEach { $0() }        callbacks[level] = []    }}// Usage:AppReadiness.shared.runWhen(level: .database) {    // Database is ready}AppReadiness.shared.runWhen(level: .ui) {    // UI is ready}

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:

Performance#

  • 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.