NSDocument on macOS: Lifecycle, Autosave, and Version Recovery
Build robust macOS document apps with NSDocument lifecycle boundaries, format-aware I/O, in-place autosave, recovery, versions, and coordinated files.
NSDocument is not just a convenience wrapper around a file URL. It is the in-memory owner of a document’s data and editing state, connected to one or more window controllers and managed by NSDocumentController. That architecture gives an AppKit application a consistent path for New, Open, Save, Save As, revert, close, and document restoration. It also establishes boundaries that custom code must respect: document state, serialization, file-system operations, and windows are related, but they are not the same object or lifecycle.
This guide focuses on AppKit document-based applications. It does not treat NSDocument as a replacement for NSFileCoordinator in arbitrary file workflows; AppKit provides coordination behavior for supported document scenarios, while applications with custom packages and cross-process access still need to follow the relevant file-coordination contract.
Separate the document model from its windows
A custom NSDocument subclass represents one open document, whether it has no window yet, one window, or multiple views. NSWindowController manages a document window. The shared NSDocumentController creates, opens, tracks, and closes documents and maps declared file types to document classes. The UI should edit the document’s model through clear actions or bindings; it should not make the window controller the accidental authority for serialized data.
Declare supported document types in the app’s information property list, including a type identifier, role, and the document class that handles the format. Check both opening and saving: a document type mapping can work for double-click/Open while a missing or incorrect editor declaration leaves New or Save As with an unexpected type. The document controller’s type mapping is runtime configuration, not a file extension guess scattered through view code.
For a simple UTF-8 text format, a document subclass can centralize validation and serialization:
import AppKit
final class TextDocument: NSDocument {
private(set) var text = ""
override func read(from data: Data, ofType typeName: String) throws {
guard typeName == "com.example.plain-text" else {
throw CocoaError(.fileReadUnknown)
}
guard let decoded = String(data: data, encoding: .utf8) else {
throw CocoaError(.fileReadCorruptFile)
}
text = decoded
}
override func data(ofType typeName: String) throws -> Data {
guard typeName == "com.example.plain-text" else {
throw CocoaError(.fileWriteInvalidFileName)
}
return Data(text.utf8)
}
}
The example assumes the app’s declared type identifier matches the format. A real editor also needs model mutation methods that call updateChangeCount(_:) when an edit occurs, and it should preserve useful error context for malformed input. For a multi-file document package, override the file-wrapper reading and writing path instead of flattening a directory structure into one Data value.
Use the high-level save(...) and revert(...) APIs to request those operations. The data(ofType:), fileWrapper(ofType:), and read methods are serialization extension points, not general-purpose replacements for the document lifecycle. The framework may invoke writing methods with temporary locations or filenames that do not resemble fileURL; write code must honor the parameters it receives and must not infer that a target path is the current document’s permanent location.
Edited state and close behavior
NSDocument tracks whether a document has unsaved changes. A user action that changes persistent model content should update that state, typically through updateChangeCount(.changeDone) or an undo-manager operation. A view-only selection change generally should not mark the file edited. If the model is changed through a binding or child controller, ensure the edit notification is not bypassed; otherwise the title-bar indicator and save/close prompts can be wrong even though the screen appears to contain edits.
Closing is a workflow, not a call to deallocate a window. The document controller asks whether edited documents should be saved, reviewed, or discarded before quitting. If custom shutdown code bypasses this path, it can lose data or leave documents registered as open. Test the user-facing commands and application termination separately: Save, Save As, Revert, Close, Quit with one edited document, and Quit with multiple edited documents.
Undo and redo are also part of the document contract. A document subclass is responsible for implementing undo behavior that reflects model changes. Keep undo registration aligned with state mutation and save-state changes; a serialized snapshot that omits fields users can undo is a sign that the document model and editor command model are drifting apart.
Autosave has distinct meanings
AppKit exposes several related but different behaviors: periodic autosaving for crash protection, autosaving in place, draft autosaving, and preservation of document versions. Do not collapse them into a claim that “the framework always saves everything.” The subclass’s autosavesInPlace property defaults to false; return true only when the format and write implementation safely support in-place autosaves. This declaration is consulted in several lifecycle decisions and may be queried off the main thread. It is not a runtime report that a particular save is happening in place. Inspect the save-operation type passed to writing methods when behavior depends on the operation.
scheduleAutosaving() schedules periodic autosaving when autosaving is enabled and the document has unsaved changes. The default scheduling timing is an implementation detail that can change between macOS releases. Call updateChangeCount as edits occur and let AppKit manage its schedule unless the product has a documented reason to override it. Do not build correctness around an assumed interval or tell users that periodic autosave has completed without observing the save result.
Autosaving drafts and autosaving in place are separate capabilities. A draft can be written to an autosave location while the original remains unchanged; an autosave-as operation can write a new file and move the document’s current location. A user-requested Save As is likewise distinct from writing a copy. Serialization code should be operation-aware where needed, and it should not treat every write as an overwrite of the original.
If serialization is asynchronous or expensive, use AppKit’s supported save flow and report errors through its completion mechanism. Keep the model stable for the duration of serialization: create a consistent snapshot or protect access to mutable data, and do not block the main thread waiting on a writer that needs main-thread state. A file package should be staged and committed as a coherent unit rather than exposing partially updated children.
File coordination and versions
NSDocument implements file-coordination support required for iCloud-enabled document applications and provides safe movement/rename behavior in document workflows. This does not mean every custom read or write elsewhere in the application is coordinated automatically. If a separate process, file provider, or another part of the app can mutate the same item, use the documented coordination APIs at that boundary and avoid starting a second synchronous coordination operation from inside a presenter callback.
Version preservation is another explicit capability. preservesVersions indicates whether the subclass supports version management. A file’s backup or autosaved contents URL is not the same thing as a guarantee that every desired historical version exists. Verify version browsing, document replacement, Save As, and package behavior on the storage locations the product supports. Do not delete autosave or backup artifacts as “temporary files” without proving the framework no longer needs them.
Document restoration has two kinds of state. The file contains the durable model. Restorable interface state may capture selection, scroll position, or which subview was open, and it should not silently replace the model’s source of truth. Keep restoration payloads versioned and defensive: a new app version must tolerate missing or unknown keys and a document may open without a valid prior window state.
Failure paths worth designing explicitly
Opening can fail because bytes are malformed, a format is unsupported, a package is incomplete, access is denied, or a provider has not downloaded the content yet. Throw useful read errors and preserve the original file. Saving can fail due to disk exhaustion, permission changes, a disconnected destination, conflict, or a serialization bug. Keep the document marked edited after a failed save and expose an actionable error; do not clear the dirty state just because serialization started.
Never assume every write targets fileURL. Safe-save implementations can write to a temporary directory and replace the original only after the new representation is ready. Likewise, the file’s display name, extension, and chosen type may change during Save As or autosave. Test those transitions by saving to a new folder, changing the format where supported, and reopening the resulting item through the document controller.
Concurrent opening is another opt-in decision. If multiple documents of the same type can be read independently, the subclass may declare that capability. Shared mutable singletons, global parsers, or caches can make concurrent reads unsafe even if each document instance is separate. Test parallel Open operations and ensure any shared service is thread-safe before opting in.
A document-focused test matrix
Use temporary local files and a package fixture to test the full lifecycle. Verify New creates the declared default type; Open routes each supported type to the correct subclass; malformed data fails without overwriting the source; a change marks the document edited; Save clears the dirty state only on success; Save As changes the document location; and Revert reloads the last saved representation.
Then test autosave and recovery as separate scenarios. Force a save error and confirm that the failure is visible and retryable. Terminate the app after a completed autosave and verify the documented recovery path on the next launch. Test an unsaved untitled document, a large file, an iCloud or provider-managed item if supported, and a package during a coordinated external read. Confirm that restore-state data can be absent or stale without corrupting document content.
Instrument open, parse, save, replace, and close boundaries with duration and result categories. Avoid logging document contents or paths that may contain private names. Useful evidence includes the document type, operation kind, byte or package-entry counts, elapsed time, and sanitized error code. Acceptance should require that an app can reopen its saved output, never reports a failed save as clean, routes every declared type correctly, and preserves edits across the supported recovery paths.
The AppKit document architecture works best when it owns lifecycle transitions while the subclass owns a precise serialization contract. Keep the in-memory model separate from windows, mark edits intentionally, let the framework coordinate supported save operations, and test autosave, drafts, versions, and restoration as distinct behaviors. That produces a document editor whose correctness does not depend on a particular window remaining alive or a file operation always targeting the same path.
Related:
- macOS File Coordination: NSFileCoordinator and NSFilePresenter Without Deadlocks
- File Provider on macOS: Domains, Placeholders, and System-Managed Sync
Sources: