Persisting Tauri Window State on macOS
A practical pattern for persisting window size and placement in Tauri apps, based on the implementations in subtraktr and Osprey.
- tauri
- desktop-apps
- architecture
I have now implemented the same small piece of desktop-app behavior twice: when the app reopens, put the window back where the user left it.
The first time was in subtraktr. The second time was in Osprey. Both are Tauri apps, both need to feel native enough on macOS, and both ran into the same practical question: where should window size and window placement live?
Tauri has an official window-state plugin
that saves and restores window position and size. Its JavaScript API exposes
flags for size and position through
@tauri-apps/plugin-window-state.
That is the right place to start if the application only needs normal window
state persistence.
In these two apps, I ended up with a custom Rust implementation instead. The reason was not that window state is complicated. The reason was that the unsafe version is too easy to write.
The naive version is:
- Read the current x, y, width, and height.
- Write them somewhere.
- On the next launch, put the window back there.
That works until it does not. A monitor gets disconnected. A laptop moves between docked and undocked setups. Display scaling changes. The saved rectangle is technically valid but no longer visible. Now the app has remembered the window so faithfully that the user cannot see it.
The useful implementation has a slightly different rule:
Persist size and position, but only restore the position when it is still safe.
What Both Apps Store
Both subtraktr and Osprey store a small JSON file called window_state.json in
Tauri’s app data directory. On macOS, that means the file lives under
~/Library/Application Support/ for the active app identifier.
That app identifier matters. The development and release builds use different identifiers, so their saved window state does not collide:
- subtraktr release:
com.subtraktr.app - subtraktr development:
com.subtraktr.app.dev - Osprey release:
io.cursedfunction.osprey - Osprey development:
io.cursedfunction.osprey.dev
The persisted payload is intentionally small:
{
"x": 120.0,
"y": 80.0,
"width": 1100.0,
"height": 760.0,
"monitor_name": "Built-in Retina Display"
}
The app stores logical coordinates, not raw physical pixels. Tauri reports some window measurements in physical units, so both apps divide by the current scale factor before writing the file. On macOS, where Retina and external-display setups are common, that one detail keeps the saved state from being more fragile than it needs to be.
The shape also separates two ideas that are easy to mix together:
widthandheightdescribe the inner content size to restore.xandydescribe the outer window frame position.
That is deliberate. The user mostly experiences size as “how much app content fits in the window,” but placement belongs to the native window frame.
Why Rust Owns It
Both implementations live in the Tauri/Rust side of the app, not in React state or web storage.
That keeps window geometry where it belongs: next to the native window APIs. Rust can save on native window events even if the frontend never gets involved, and restore can happen during Tauri setup before normal frontend behavior has started.
It also keeps the file in the OS app data directory through
app.path().app_data_dir(). I do not want desktop-window placement stored in a
portable workspace file, a project export, or webview localStorage. It is
machine-local preference data.
The Save Flow
The save side is straightforward:
- Listen only to the main window.
- On move, resize, or scale-factor change, capture a throttled snapshot.
- On close or destroy, force one final save.
- Read
inner_size(),outer_position(),scale_factor(), andcurrent_monitor(). - Convert physical values to logical values.
- Reject invalid data before writing.
- Serialize the state to
window_state.json.
Both apps use a small in-memory cache so moving or resizing the window does not rewrite the file constantly. Non-final saves are throttled. Final saves on close or destroy are forced.
The state validation is intentionally boring: finite numbers only, and positive width and height. If the current window state cannot be read or does not pass that check, skip the write.
The Restore Flow
Restore is where the implementation earns its keep.
The app loads window_state.json during Tauri setup. If the file is missing,
corrupt, or invalid, the app uses the default window configuration from
tauri.conf.json.
If the saved state is valid, restoration happens in two phases:
- Restore the size.
- Restore the position only if the rectangle is visible.
Size restoration is forgiving. If the saved size is smaller than the current minimum window size, clamp it to the minimum and continue. This handles the case where an older version of the app allowed a smaller window than the current design can support.
Position restoration is stricter. The app asks Tauri for the available monitors, normalizes each monitor work area into logical pixels, and checks whether the entire saved rectangle fits inside one of those work areas.
The check is basically:
x >= monitor.work_x
y >= monitor.work_y
x + width <= monitor.work_x + monitor.work_width
y + height <= monitor.work_y + monitor.work_height
If a monitor_name was saved, the app only considers a monitor with that name.
That is not a permanent hardware identity, but it is a useful guard. If the
monitor is gone, renamed, rearranged, or no longer has room for the saved
rectangle, the app does not restore the old position.
The fallback is not dramatic. It just leaves Tauri’s default/current position in place. Restoring the size without restoring the position is still a good outcome. Opening off-screen is not.
What Changed The Second Time
The Osprey implementation is very close to the subtraktr implementation, which is a good sign. The second pass did not reveal a totally different solution. It confirmed the useful boundaries:
- The storage shape stayed the same.
- The app-data location stayed the same.
- The save events stayed the same.
- The two-phase restore stayed the same.
- The pure monitor-visibility test stayed the same.
The main change the second time was confidence in the boundaries. Window state does not need to know about the frontend framework, the current route, or the shape of the app’s domain data. It needs the native window, a small local JSON file, a safe restore rule, and tests around the geometry decisions.
That is the second lesson: do not bundle all desktop-shell concerns together. Remembering the window’s geometry is its own behavior. It should stay narrow enough that it can be moved from one Tauri app to another without pulling along unrelated UI decisions.
Tests Worth Having
The valuable tests are not UI tests that launch the whole desktop app. The most important logic is small and pure enough to unit test:
- valid finite state is accepted
- invalid dimensions are rejected
- saved size is clamped to the current minimum
- a saved monitor name must match before restoring to that monitor
- a missing monitor rejects the saved position
- a position with no monitor name can restore on any monitor that fits
- a partially off-screen rectangle is rejected
- an empty monitor list has an explicitly documented behavior
Both apps chose to allow position restore if Tauri cannot provide monitor data or returns an empty monitor list. That is a tradeoff. It avoids being overly defensive when the platform cannot answer, while still preventing the common case where monitor data exists and shows the rectangle is unsafe.
Manual macOS Checks
For macOS, I would smoke test the behavior like this:
- Delete the dev app’s
window_state.jsonfrom~/Library/Application Support/<app-id>/. - Launch the app and confirm it uses the configured default size.
- Resize the window, quit, reopen, and confirm size persists.
- Move the window, quit, reopen, and confirm position persists.
- Move the window to an external display, quit, reopen, and confirm it returns there while that display is connected.
- Save the window on an external display, disconnect the display, reopen, and confirm the app opens on a visible monitor.
- Corrupt the JSON file and confirm the app falls back without crashing.
This post is scoped to macOS because that is where I am exercising the behavior right now. The same Tauri APIs and the official plugin are desktop-oriented rather than macOS-only, but Windows and Linux deserve their own smoke tests. Different window managers, monitor naming behavior, and display-scaling setups can change the edge cases even when the implementation shape is portable.
The Pattern I Would Reuse
After doing this twice, this is the pattern I would start from in the next Tauri app:
- Decide whether the official Tauri window-state plugin is enough.
- If the app needs custom behavior, keep the implementation in Rust.
- Store
window_state.jsoninapp.path().app_data_dir(). - Let dev and release builds use separate Tauri identifiers.
- Persist logical inner size, logical outer position, and best-effort monitor name.
- Save on move, resize, scale-factor change, close, and destroy.
- Throttle ordinary saves and force the final save.
- Restore size first, clamped to current minimums.
- Restore position only when the full rectangle fits inside an available monitor work area.
- Keep the monitor validation pure enough to test without a running app.
- Keep unrelated window-chrome decisions out of the persistence layer.
The implementation is small. The discipline is in the boundaries: native state belongs in the native shell, machine-local preferences belong in app data, and a remembered position should never be trusted more than the user’s ability to see the app.