Innovative iOS Patterns from Signal's Codebase


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

// Measure system load by monitoring display link performance
private 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:

// Updates immediately - causes jank with rapid changes
NotificationCenter.default.addObserver { _ in
    self.tableView.reloadData()
}

// Fixed timer - not adaptive
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
    self.updateUI()
}

Signal's approach:

// 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

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.

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:

// Presenting modally - can be dismissed, interferes with navigation
present(callViewController, animated: true)

// Adding subview to window - wrong z-order, rotation issues
UIApplication.shared.keyWindow?.addSubview(callView)

Signal's approach:

// Dedicated windows with precise z-ordering
// Each window has its own view controller hierarchy
// No interference between different UI concerns
callViewWindow.windowLevel = ._callView
screenBlockingWindow.windowLevel = ._screenBlocking

Advanced Technique: Private API Workarounds

Signal even uses encoded selectors to work around iOS bugs:

// iOS has a rotation bug with certain window configurations
// Access private API safely using encoded selector names
private 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

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.

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)

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)

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):

// Only supports "last only" mode
class 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:

// Choose the right mode for the use case
let searchDebouncer = DebouncedEvent(mode: .lastOnly, maxFrequencySeconds: 0.3)
let scrollDebouncer = DebouncedEvent(mode: .firstLast, maxFrequencySeconds: 0.05)

// Searching: waits for user to stop typing
searchDebouncer.request { performSearch() }

// Scrolling: immediate first update, then throttled
scrollDebouncer.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

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()
                }
            }
        }
    }
}

// Usage
let 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:

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.

@propertyWrapper
public 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 types
public 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 collections
public 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

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

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

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):

// Data race - multiple threads can read/write simultaneously
class UnsafeCounter: @unchecked Sendable {
    var count = 0  // Not thread-safe!

    func increment() {
        count += 1  // Race condition
    }
}

With manual locking (verbose):

// Works but verbose and error-prone
class 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:

// Clean, safe, and Sendable
class 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

// 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:

@propertyWrapper
struct 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)
    }
}

// Usage
class 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.

let start = Date()
// User adjusts clock backward 1 hour
let 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.

/// 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

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

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

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):

// Breaks when clock changes
class 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):

// Always increases, immune to clock changes
class 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

// Copy Signal's implementation or create simplified version
struct 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 examples
class 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:

// Before
class 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:

// After
class 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.

/// Custom UICollectionView that protects against UIKit's temporary zero-frame assignments
/// which can cause layout calculation crashes in conversation view
class 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:

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:

*** 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):

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

With protection (stable):

// Maintains layout integrity
class 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:

// 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:

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):

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:

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:

// Rigid initialization - all dependencies needed upfront
class 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 first
let 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.

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 instances
let 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:

// Database needs account info to decrypt
// Account manager needs database to read settings
// Network manager needs both

class 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

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):

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 manager
func 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):

// All dependencies created upfront
class 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 expensive
let obj = Traditional(
    heavy1: HeavyService(),  // 100ms
    heavy2: HeavyService(),  // 100ms
    heavy3: HeavyService()   // 100ms - might not even be used!
)

Signal's approach (flexible):

// Dependencies created only when needed
class 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 instant
let 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:

class Service {
    private let dependency: () -> Dependency

    init(dependency: @escaping () -> Dependency = { DefaultDependency.shared }) {
        self.dependency = dependency
    }
}

2. With Memoization:

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:

class Service {
    private let dependency: () -> any DependencyProtocol

    init(dependency: @escaping () -> any DependencyProtocol) {
        self.dependency = dependency
    }
}

How to Apply This in Your App

Basic Pattern:

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)
    }
}

// Usage
let vc = MyViewController(
    database: { AppDelegate.shared.database },
    networkManager: { AppDelegate.shared.networkManager }
)

Advanced: Solving Circular Dependencies:

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:

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.

/// LRU Cache that adjusts size based on app context
/// Main app gets large cache, NSE gets smaller cache
public 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 environment
protocol 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

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

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

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):

// Same cache size everywhere
class 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):

// Adapts to environment
class 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:

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:

import Foundation

class 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()
    }
}

// Usage
let imageCache = ContextAwareCache<String, UIImage>(
    mainAppLimit: 500,
    extensionLimit: 32
)

Advanced: Memory Cost Aware:

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)
    }
}

// Usage
let imageCache = SizeAwareCache<String, UIImage>(
    mainAppMemoryMB: 50,  // 50MB for main app
    extensionMemoryMB: 5   // 5MB for extension
)

// Store with actual memory cost
let image = UIImage(...)
let imageSize = image.pngData()?.count ?? 0
imageCache.set("key", value: image, costInBytes: imageSize)

Production-Ready with Logging:

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.

/// Read cache that automatically invalidates when another process modifies the database
class 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 notification
class 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
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)
    }
}
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

┌─────────────────────────────────────────────────────────────────┐
│  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):

// Main app caches become stale when NSE writes
class 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):

// Cache automatically invalidates when ANY process writes
class 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:

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:

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
/// Coordinates app initialization to prevent launch stampede
@objc
public 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

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)

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)

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

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

Real Initialization Timeline:

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):

// Everything runs at once - app freezes
class 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):

// Staged initialization - responsive app
class 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:

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:

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.