Skip to content
macOSDeep Dive Published Updated 4 min readViews unavailable

Responding to macOS Memory Pressure with Dispatch Sources

Use a Dispatch memory-pressure source to shed reclaimable work, serialize transitions, and avoid treating a system signal as an app memory limit.

An application can receive macOS memory-pressure transitions without polling Activity Monitor or guessing from its own resident size. Grand Central Dispatch provides a memory-pressure source with normal, warning, and critical event flags: Apple defines these respectively as pressure returned to normal, pressure at the warning stage, and pressure at the critical stage. The signal describes system memory pressure; it is not a byte budget for one process, a leak detector, or a guarantee that a particular allocation will succeed.

Use the event to change memory use for work that starts after the notification. Apple’s libdispatch guidance recommends reducing cache sizes for newly initiated operations and explicitly warns against traversing and discarding existing caches for past operations: the resulting VM work can aggravate system pressure. The policy below lowers future cache admission and pauses speculative work; it does not walk or purge an old cache in the pressure handler.

Create and own one source

The Swift API creates an inactive source. Install its handler, retain it in an owner with a clear lifetime, and then activate it. The handler should be small and serialized with the component that owns the affected caches:

final class PressureObserver {
    private var source: (any DispatchSourceMemoryPressure)?
    private let policy: CachePolicy

    init(policy: CachePolicy) {
        self.policy = policy
        let source = DispatchSource.makeMemoryPressureSource(
            eventMask: [.normal, .warning, .critical],
            queue: DispatchQueue(label: "example.memory-pressure")
        )
        source.setEventHandler { [weak self] in
            guard let self, let source = self.source else { return }
            let events = source.data
            if events.contains(.critical) {
                self.policy.stopNewOptionalCacheAdmissions()
                self.policy.pauseSpeculativeWork()
            } else if events.contains(.warning) {
                self.policy.reduceCacheBudgetForNewOperations()
            } else if events.contains(.normal) {
                self.policy.restoreCacheBudgetForNewOperationsGradually()
            }
        }
        self.source = source
        source.activate()
    }

    deinit {
        source?.cancel()
    }
}

The state transitions shown are policy examples, not Apple-prescribed cache sizes. Make each action idempotent: repeated warning delivery must not repeatedly shrink the budget or enqueue duplicate work. In this branch order, gradual restoration happens only when the callback contains .normal and neither the warning nor critical branch takes precedence; otherwise, restore only after a separate observation confirms that current pressure is normal. This illustrative branch is not a complete pressure-state reconciler: production code should reconcile current pressure state, or use an independent conservative recovery policy when it cannot determine that state. Recovery should respect user-selected quality and concurrency settings. Avoid reacting to pressure by scheduling a traversal or purge of cache entries from past operations; only use a narrow predesigned purge if it requires no expensive traversal or VM work.

Dispatch sources are not reentrant. Apple documents that events received while a handler is running or the source is suspended are coalesced and delivered after processing resumes; that does not guarantee a later normal-only callback after pressure subsides. Treat the handler as a state-change notification, not as a lossless event log. Apply a small admission-policy update and return promptly; do not launch cache scans, memory-heavy cleanup, or I/O from the pressure callback. Keep access to the cache policy synchronized with ordinary application operations.

Separate pressure response from memory accounting

The source reports pressure categories, not which process caused them or how many bytes a cache must release. Capture your own allocation, cache, and workload metrics if you need diagnosis. Keep memory pressure distinct from thermal state, power mode, and application-specific memory warnings; those signals have different meanings and may require separate responses.

Do not allocate a large test buffer to force a critical transition on a developer’s Mac. Unit-test the policy with injected states and exercise the integration on a controlled test machine with a bounded workload. Verify that future operations admit less optional cache data and pause speculative allocations, existing caches are not traversed by the handler, active document state remains intact, and normal operation resumes gradually only after a normal-only event or a separate observation confirms current pressure is normal.

Validate teardown and recovery

Test source creation and activation, repeated state notifications, cancellation during owner teardown, and the application’s return from critical to normal pressure. Ensure that the event handler cannot retain a view controller or cache owner indefinitely. If a lower-level wrapper or allocation policy can fail, test that failure path separately; the shown Swift factory itself is non-optional and non-throwing. If multiple subsystems react to pressure, centralize admission policy or make ownership explicit so they do not each trigger expensive work against existing caches.

The API is a useful early signal for resource adaptation, not a replacement for bounded caches, cancellation, or allocation error handling. Keep the response reversible, protect durable user state, and report the observed state without claiming the source identified a particular culprit.

Related:

Sources:

Comments