Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

AppKit Application Lifecycle on macOS: Launch, Open Events, and Termination

Coordinate AppKit launch and quit transitions with delegate callbacks, document ownership, deferred termination, and recoverable shutdown work.

An AppKit application lifecycle is more than “run startup code, then quit.” The system can launch an app because the user opened it, because a document or URL was selected, or because another system service requested work. The app may finish launching before it has restored every document window, and it may receive another open request while already running. On the way out, the user can have unsaved documents, in-flight writes, a pending migration, or cleanup work that cannot safely be ignored.

NSApplication owns the event loop and routes lifecycle messages to its delegate. Document-based apps also have NSDocumentController and per-document lifecycle rules. Keep application-wide initialization, document opening, window restoration, and termination coordination distinct so that a callback does not become a catch-all for unrelated work.

Launch callbacks are boundaries, not universal startup hooks

AppKit exposes both “will finish launching” and “did finish launching” notifications and delegate methods. Use the earlier boundary for work that must be ready before the app is considered fully launched, and the later boundary for setup that needs the initialized application environment. Avoid doing slow network requests, large indexing jobs, or heavyweight document reads synchronously in either callback; launch latency is visible to users and may affect whether a system-requested open appears to hang.

An open request can include files or URLs and may arrive through delegate methods. Route it to the correct owner: NSDocumentController for document types it manages, or a focused application service for URLs the app owns. Validate input and support multiple requested items if the API delivers them. Do not assume that the app was launched with no arguments or that the first window should always be a blank document.

Startup should be idempotent. A notification may be observed by several components, and restoration or a repeated open request can revisit the same logical document. Use stable model identities and explicit state to avoid duplicate windows, duplicate imports, or repeated side effects. If an operation is asynchronous, associate it with the initiating request and handle cancellation if the app’s state changes.

Separate app lifecycle from window and document lifecycle

Closing the last window does not necessarily mean the application quits. Whether that should happen depends on product policy and the delegate’s applicationShouldTerminateAfterLastWindowClosed behavior. A document app may remain active with no open windows so the user can create another document or select a menu command. A utility app may choose to quit after its final window closes. Make the policy deliberate instead of tying process lifetime to the incidental count of visible windows.

Window restoration is another separate contract. AppKit can ask the application to recreate restorable windows, but the application must supply stable window identity and recover the associated domain object. A restored window should not cause the app to reopen an unrelated document or start duplicate background work. Treat restored state as a hint that may refer to a file or account that no longer exists.

Document close and application termination also differ. NSDocument can manage save prompts and document-specific data flow. Do not reimplement those decisions in an application delegate unless the app owns a separate non-document resource that genuinely needs coordination. Conversely, do not assume a document save automatically waits for every app-level queue, helper, or export task to finish.

Treat quit as a state machine

The delegate’s applicationShouldTerminate method returns whether termination should proceed, be cancelled, or be delayed. Most apps should allow ordinary termination once document handling and required shutdown policy are satisfied. If a brief asynchronous operation must finish first, NSTerminateLater means the app must later call reply(toApplicationShouldTerminate:) with the result. A delayed decision is a protocol, not a background task that can be forgotten.

Before returning the delayed result, record that a termination request is in progress and prevent duplicate shutdown coordinators. Define what happens if a second quit request arrives, if the asynchronous operation fails, or if the user cancels. Bound the wait. An app that remains stuck in a “quitting” state because a network request never completes is worse than one that reports the operation was not completed.

import AppKit

@MainActor
final class AppLifecycleDelegate: NSObject, NSApplicationDelegate {
    private var terminationPending = false
    private let flushLocalWrites: () async -> Bool

    init(flushLocalWrites: @escaping () async -> Bool) {
        self.flushLocalWrites = flushLocalWrites
    }

    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
        guard !terminationPending else {
            return .terminateCancel
        }

        terminationPending = true
        let flush = flushLocalWrites
        Task { @MainActor in
            let success = await flush()
            self.terminationPending = false
            sender.reply(toApplicationShouldTerminate: success)
        }
        return .terminateLater
    }
}

The injected operation is application-specific; in a real implementation it needs an explicit deadline, cancellation behavior, and a policy for errors. The reply must occur exactly once and on the actor expected by the delegate. Never wait synchronously on the main thread for work that needs that thread to complete. A real delegate should also decide whether to cancel quickly when a second termination request arrives.

Persist recoverable state before the final callback

applicationWillTerminate is a final notification, not a reliable place to perform all persistence. Save user data at meaningful transaction boundaries and use autosave or durable checkpoints where appropriate. The termination callback can do lightweight cleanup and final diagnostics, but it should not be the only place that stores unsaved work or drains an unbounded queue.

Separate essential local state from best-effort state. Essential work includes committed document changes or a durable transaction that the app has already promised to preserve. Best-effort work might include refreshing a cache or updating a noncritical usage metric. Do not keep the app alive indefinitely for work that can safely be retried after the next launch.

If writes are in flight, define an explicit shutdown coordinator. Stop accepting new work, cancel work that is no longer required, await only bounded operations, and record any remaining work in a durable queue if the app supports recovery. Each operation should have an idempotency key or transaction identity so a retry after relaunch does not duplicate an export or server-side mutation.

Open events and duplicate requests

Opening a file from Finder may launch the app or send a request to an already-running process. Handle file URLs based on type declarations and open behavior, not on string suffixes alone. If the document is already open, activate or reuse the existing document according to the product’s multiwindow rules. When the same file arrives twice during launch, coalesce duplicate work rather than creating two imports.

URL handling has similar concerns. Validate scheme, host, path, and allowed parameters. A URL may arrive before the UI is ready; enqueue a bounded request in application state and process it after the required service is initialized. Do not present a modal window from a callback before the main event loop and scene/window setup can handle it coherently.

For long-running imports, acknowledge the open request with a defined success or failure result where the API provides a reply. Avoid reporting success before the app has actually accepted the input. Capture a correlation identifier and state transition so logs distinguish “request received,” “document opened,” “import completed,” and “operation failed.”

Shutdown, sudden termination, and recovery

Applications should not rely on always receiving a graceful termination callback for every possible system event or crash. The durable design is to keep persisted state consistent during normal work and make startup recovery idempotent. Temporary files should have ownership metadata or a recognizable naming scheme; recovery should distinguish a complete committed file from a partial write.

When an app has a user-facing unsaved document, follow the document framework’s save and close flow. When a background task has external side effects, make its state machine durable before dispatching that side effect. When an operation can be cancelled, cancellation should leave the model in a valid state and clear its activity indicators. On next launch, reconcile interrupted operations instead of assuming the last process exited cleanly.

Avoid changing app activation or presenting a window from arbitrary background queues during shutdown. Stop timers and observers under their owners. Release file handles, listeners, and temporary resources deterministically, but do not race a queued callback that still assumes those objects are alive. A shutdown coordinator should own this ordering rather than asking every view controller to guess when the process will end.

Test lifecycle edges

Test a fresh launch, launch with an open document, open while already running, repeated open of the same file, restoration with a missing file, last-window close, user quit with dirty documents, deferred quit success, deferred quit cancellation, and a simulated hung operation. Verify the app does not deadlock and that a failed save leaves a clear recovery path.

Use structured, privacy-conscious state logging around launch and termination. Record lifecycle stage, request type, operation identifier, elapsed time, and result. Do not log document contents, credentials, or full sensitive paths. An event trace should make it possible to tell whether the failure occurred before app initialization, during document dispatch, during save coordination, or while waiting on deferred termination.

Acceptance criteria

Every open request reaches one owner, duplicate requests follow explicit policy, launch remains responsive, and the app’s last-window behavior matches the product design. A deferred termination returns exactly one reply within a bounded time. Critical state survives process interruption because it was committed during normal operation, not merely in a final callback. On relaunch, interrupted background work can be reconciled without duplicating external effects.

AppKit provides the lifecycle callbacks and the event loop; the application defines its recovery and ownership rules. Keeping those responsibilities separate makes launch, document opening, window restoration, and quit behavior understandable under both ordinary use and failure.

Related:

Sources:

Comments