docs/src/content/docs/concepts/lifecycle.mdx
import { Tabs, TabItem } from "@astrojs/starlight/components";
Desktop applications have a lifecycle from startup to shutdown. Wails v3 provides services, events, and hooks to manage this lifecycle effectively.
direction: down
Start: "Application Start" {
shape: oval
style.fill: "#10B981"
}
Init: "Initialisation" {
Parse: "Parse Options" {
shape: rectangle
}
Register: "Register Services" {
shape: rectangle
}
Setup: "Setup Runtime" {
shape: rectangle
}
}
AppRun: "app.Run()" {
shape: rectangle
style.fill: "#3B82F6"
}
ServiceStartup: "Service Startup" {
shape: rectangle
style.fill: "#8B5CF6"
}
EventLoop: "Event Loop" {
Process: "Process Events" {
shape: rectangle
}
Handle: "Handle Messages" {
shape: rectangle
}
Update: "Update UI" {
shape: rectangle
}
}
QuitSignal: "Quit Signal" {
shape: diamond
style.fill: "#F59E0B"
}
ShouldQuit: "ShouldQuit Check" {
shape: rectangle
style.fill: "#3B82F6"
}
OnShutdown: "OnShutdown Callbacks" {
shape: rectangle
style.fill: "#3B82F6"
}
ServiceShutdown: "Service Shutdown" {
shape: rectangle
style.fill: "#8B5CF6"
}
Cleanup: "Cleanup" {
Close: "Close Windows" {
shape: rectangle
}
Release: "Release Resources" {
shape: rectangle
}
}
End: "Application End" {
shape: oval
style.fill: "#EF4444"
}
Start -> Init.Parse
Init.Parse -> Init.Register
Init.Register -> Init.Setup
Init.Setup -> AppRun
AppRun -> ServiceStartup
ServiceStartup -> EventLoop.Process
EventLoop.Process -> EventLoop.Handle
EventLoop.Handle -> EventLoop.Update
EventLoop.Update -> EventLoop.Process: "Loop"
EventLoop.Process -> QuitSignal: "User quits"
QuitSignal -> ShouldQuit: "Check allowed?"
ShouldQuit -> EventLoop.Process: "Denied"
ShouldQuit -> OnShutdown: "Allowed"
OnShutdown -> ServiceShutdown
ServiceShutdown -> Cleanup.Close
Cleanup.Close -> Cleanup.Release
Cleanup.Release -> End
Create your application with application.New():
app := application.New(application.Options{
Name: "My App",
Description: "An application built with Wails",
Services: []application.Service{
application.NewService(&MyService{}),
},
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
What happens:
Call app.Run() to start the application:
err := app.Run() // Blocks until quit
if err != nil {
log.Fatal(err)
}
What happens:
The application enters the event loop where it spends most of its time:
When the application quits:
ShouldQuit callback is checked (if set)OnShutdown callbacks are executedServices are the primary way to manage lifecycle in Wails v3. They provide startup and shutdown hooks through interfaces. For complete documentation on services, see the Services guide.
type MyService struct {
db *sql.DB
}
// ServiceStartup is called when the application starts
func (s *MyService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
var err error
s.db, err = sql.Open("sqlite3", "app.db")
if err != nil {
return err // Startup aborts if error returned
}
// Run migrations
if err := s.runMigrations(); err != nil {
return err
}
return nil
}
// ServiceShutdown is called when the application shuts down
func (s *MyService) ServiceShutdown() error {
if s.db != nil {
return s.db.Close()
}
return nil
}
app := application.New(application.Options{
Services: []application.Service{
application.NewService(&MyService{}),
application.NewService(&AnotherService{}),
},
})
Key points:
ServiceStartup returns an error, the application abortsctx passed to ServiceStartup is cancelled when shutdown beginsThe context passed to ServiceStartup is valid for the application's lifetime:
func (s *MyService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
// Start a background task that respects shutdown
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.performBackgroundSync()
case <-ctx.Done():
// Application is shutting down
return
}
}
}()
return nil
}
You can also access the context from the application instance:
app := application.Get()
ctx := app.Context()
These are convenience callbacks in application.Options that let you hook into the application lifecycle without creating a full service. They're useful for simple cleanup tasks, quit confirmation, or when you need to run code at specific points in the shutdown sequence.
For more complex lifecycle management with startup logic, dependency injection, or stateful resources, use Services instead.
The ShouldQuit callback is called whenever a quit is requested—whether by the user closing the last window, pressing Cmd+Q (macOS) / Alt+F4 (Windows), or calling app.Quit() programmatically.
Return value:
true to allow the quit to proceed (application will shut down)false to cancel the quit (application continues running)This is your opportunity to intercept quit requests and optionally prevent them, for example to prompt the user about unsaved changes:
app := application.New(application.Options{
ShouldQuit: func() bool {
if !hasUnsavedChanges() {
return true // No unsaved changes, allow quit
}
// Prompt the user — MessageDialog.Show() blocks and returns nothing.
// The button's OnClick callback fires for whichever button the user picks.
shouldQuit := false
dlg := application.Get().Dialog.Question().
SetTitle("Unsaved Changes").
SetMessage("You have unsaved changes. Quit anyway?")
quit := dlg.AddButton("Quit")
cancel := dlg.AddButton("Cancel")
dlg.SetDefaultButton(cancel)
dlg.SetCancelButton(cancel)
quit.OnClick(func() { shouldQuit = true })
dlg.Show()
return shouldQuit
},
})
If ShouldQuit is not set, the application will quit immediately when requested.
When ShouldQuit is called:
DisableQuitOnLastWindowClosed is set)app.Quit()When ShouldQuit is NOT called:
os.Exit() is called directlyThe OnShutdown callback is called when the application is confirmed to be quitting (after ShouldQuit returns true, if set). Use this for cleanup tasks like saving state, closing database connections, or releasing resources.
app := application.New(application.Options{
OnShutdown: func() {
// Save application state
saveState()
// Close connections
cleanup()
},
})
You can also register additional shutdown callbacks programmatically at any time during the application's lifecycle:
app.OnShutdown(func() {
log.Println("Application shutting down...")
})
Multiple callbacks are executed in the order they were registered. The shutdown process blocks until all callbacks complete.
Important: Keep shutdown callbacks fast (under 1 second). The operating system may force-terminate applications that take too long to quit, which could interrupt your cleanup and cause data loss.
The PostShutdown callback is called after all shutdown tasks have completed, just before the process terminates. At this point, the application instance is no longer usable—windows are closed, services are shut down, and resources are released.
This is primarily useful for:
app.Run() doesn't return (the callback ensures your code runs)app := application.New(application.Options{
PostShutdown: func() {
// Final logging
log.Println("Application terminated cleanly")
// Flush any buffered logs
logger.Sync()
},
})
Note: Do not attempt to use application features (windows, dialogs, etc.) in PostShutdown—they are no longer available.
Wails provides an event system that notifies you when things happen in your application—windows opening, the application starting, theme changes, and more. You can listen to these events to react to lifecycle changes without blocking or intercepting them.
For window events, you can also use RegisterHook instead of OnWindowEvent to intercept and cancel actions—for example, preventing a window from closing. See Window Hooks below.
For full documentation on the event system, see the Events guide.
Listen to application lifecycle events:
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {
app.Logger.Info("Application has started!")
})
Platform-specific events are also available:
// macOS
app.Event.OnApplicationEvent(events.Mac.ApplicationDidFinishLaunching, func(event *application.ApplicationEvent) {
// Handle macOS launch
})
app.Event.OnApplicationEvent(events.Mac.ApplicationWillTerminate, func(event *application.ApplicationEvent) {
// Handle macOS termination
})
// Windows
app.Event.OnApplicationEvent(events.Windows.ApplicationStarted, func(event *application.ApplicationEvent) {
// Handle Windows start
})
Listen to window lifecycle events:
window := app.Window.New()
window.OnWindowEvent(events.Common.WindowFocus, func(e *application.WindowEvent) {
app.Logger.Info("Window gained focus")
})
window.OnWindowEvent(events.Common.WindowClosing, func(e *application.WindowEvent) {
app.Logger.Info("Window is closing")
})
Use RegisterHook instead of OnWindowEvent when you need to cancel an event:
window := app.Window.New()
var countdown = 3
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
countdown--
if countdown > 0 {
app.Logger.Info("Not closing yet!", "remaining", countdown)
e.Cancel() // Prevent the window from closing
return
}
app.Logger.Info("Window closing now")
})
Difference between OnWindowEvent and RegisterHook:
OnWindowEvent: Notifies you when an event happens (cannot cancel)RegisterHook: Lets you intercept and potentially cancel the eventWindows have their own lifecycle, from creation through to destruction. Each window loads its frontend content independently and can be shown, hidden, or closed at any time. When a user attempts to close a window, you can intercept this with a RegisterHook to prompt for confirmation or hide the window instead of destroying it.
For complete window documentation, see the Windows guide.
direction: down
Create: "Create Window" {
shape: oval
style.fill: "#10B981"
}
Load: "Load Frontend" {
shape: rectangle
}
Show: "Show Window" {
shape: rectangle
}
Active: "Window Active" {
Events: "Handle Events" {
shape: rectangle
}
}
CloseRequest: "Close Request" {
shape: diamond
style.fill: "#F59E0B"
}
Hook: "WindowClosing Hook" {
shape: rectangle
style.fill: "#3B82F6"
}
Destroy: "Destroy Window" {
shape: rectangle
}
End: "Window Closed" {
shape: oval
style.fill: "#EF4444"
}
Create -> Load
Load -> Show
Show -> Active.Events
Active.Events -> Active.Events: "Loop"
Active.Events -> CloseRequest: "User closes"
CloseRequest -> Hook
Hook -> Active.Events: "Cancelled"
Hook -> Destroy: "Allowed"
Destroy -> End
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "My Window",
Width: 800,
Height: 600,
})
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if !hasUnsavedChanges() {
return
}
// MessageDialog.Show() returns nothing; per-button OnClick handlers fire.
dlg := application.Get().Dialog.Question().
SetTitle("Unsaved Changes").
SetMessage("Save before closing?")
save := dlg.AddButton("Save")
discard := dlg.AddButton("Discard")
cancel := dlg.AddButton("Cancel")
dlg.SetDefaultButton(save)
dlg.SetCancelButton(cancel)
save.OnClick(func() { saveChanges() })
cancel.OnClick(func() { e.Cancel() }) // Prevent close
_ = discard // "Discard" falls through and allows close
dlg.Show()
})
A common pattern for system tray apps:
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
window.Hide() // Hide instead of destroy
e.Cancel() // Prevent actual close
})
With multiple windows:
mainWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Main Window",
})
settingsWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Settings",
Width: 400,
Height: 600,
Hidden: true, // Start hidden
})
Default behaviour varies by platform:
| Platform | Default when last window closes |
|---|---|
| macOS | App stays running (menu bar remains) |
| Windows | App quits |
| Linux | App quits |
macOS follows native platform conventions where applications typically remain active in the menu bar even with no windows. Windows and Linux quit by default.
Make all platforms quit when last window closes:
app := application.New(application.Options{
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: true,
},
})
Make all platforms stay running when last window closes:
This is useful for system tray applications or apps that should remain running in the background.
app := application.New(application.Options{
Windows: application.WindowsOptions{
DisableQuitOnLastWindowClosed: true,
},
Linux: application.LinuxOptions{
DisableQuitOnLastWindowClosed: true,
},
})
type DatabaseService struct {
db *sql.DB
}
func (s *DatabaseService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
var err error
s.db, err = sql.Open("sqlite3", "app.db")
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
if err := s.db.PingContext(ctx); err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
return nil
}
func (s *DatabaseService) ServiceShutdown() error {
if s.db != nil {
return s.db.Close()
}
return nil
}
// Exported methods are available to the frontend
func (s *DatabaseService) GetUsers() ([]User, error) {
// Query implementation
}
type ConfigService struct {
config *Config
path string
}
func (s *ConfigService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
s.path = "config.json"
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
s.config = &Config{} // Default config
return nil
}
return err
}
return json.Unmarshal(data, &s.config)
}
func (s *ConfigService) ServiceShutdown() error {
data, err := json.MarshalIndent(s.config, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}
type WorkerService struct {
cancel context.CancelFunc
}
func (s *WorkerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
workerCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel
go s.runWorker(workerCtx)
return nil
}
func (s *WorkerService) ServiceShutdown() error {
if s.cancel != nil {
s.cancel()
}
return nil
}
func (s *WorkerService) runWorker(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.doWork()
case <-ctx.Done():
return
}
}
}
| Hook/Interface | When Called | Can Cancel? | Use For |
|---|---|---|---|
ServiceStartup | During app.Run(), before event loop | No (return error to abort) | Initialisation |
ServiceShutdown | During shutdown, after OnShutdown | No | Cleanup |
OnShutdown | When quit confirmed | No | Application cleanup |
ShouldQuit | When quit requested | Yes (return false) | Confirm quit |
RegisterHook(WindowClosing) | When window close requested | Yes (e.Cancel()) | Prevent window close |
OnWindowEvent | When event occurs | No | React to events |
OnApplicationEvent | When event occurs | No | React to events |
ShouldQuit)ApplicationShouldTerminateAfterLastWindowClosed to control quit behaviourRegisterHook)Causes:
ShouldQuit returning falseOnShutdown taking too longSolution:
// 1. Check ShouldQuit logic
ShouldQuit: func() bool {
log.Println("ShouldQuit called")
return true
}
// 2. Keep OnShutdown fast
OnShutdown: func() {
log.Println("OnShutdown started")
// Fast cleanup only
log.Println("OnShutdown finished")
}
// 3. Use context for background tasks
func (s *MyService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
go func() {
<-ctx.Done()
log.Println("Context cancelled, stopping background work")
}()
return nil
}
Solution: Return descriptive errors:
func (s *MyService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
if err := s.init(); err != nil {
return fmt.Errorf("failed to initialise: %w", err)
}
return nil
}
The error will be logged and the application will not start.
ctx.Done() in goroutinesServiceShutdownServices - Learn more about the service system Learn More →
Events System - Use events for communication Learn More →
Window Management - Create and manage windows Learn More →
Questions about lifecycle? Ask in Discord or check the examples.