Build a Desktop App with Wails #5: Real-World Features — Persisting Settings and Error Handling
The todo app we built through “Build a Desktop App with Wails #4: System Integration — Dialogs, Menus, and Window Control” has one fatal gap. Quit the app and the data is gone, because the list lives only in the memory of a Go struct. This post brings the app up to genuinely usable with three real-world features: data persistence, error handling, and logging.
It runs in eight parts (six main plus two deep-dives).
- #1 What Wails is — lightweight desktop apps in Go
- #2 Project structure and the dev loop — wails dev and bindings
- #3 Connecting Go and the frontend — method bindings and events
- #4 System integration — dialogs, menus, and window control
- #5 Real-world features — persisting settings and error handling ← this post
- #6 Build and distribution — packaging per platform
Where to store data — os.UserConfigDir #
The first question is where to save. Writing a file next to the executable breaks right after distribution, because the install location often has no write permission (Program Files on Windows, Applications on macOS). You should use the path each OS reserves for app data, and Go’s standard library hands it to you through os.UserConfigDir.
| OS | Path returned by os.UserConfigDir() |
|---|---|
| macOS | ~/Library/Application Support |
| Windows | %AppData% (C:\Users\<name>\AppData\Roaming) |
| Linux | ~/.config (XDG_CONFIG_HOME) |
Create a directory named after your app underneath it and store your files there. The same code points at the correct location on all three OSes with no platform branches.
Store — saving and loading JSON #
We build a small store type that handles the todo list as a file. JSON is plenty as a format.
type Store struct {
path string
}
func NewStore(appName string) (*Store, error) {
base, err := os.UserConfigDir()
if err != nil {
return nil, err
}
dir := filepath.Join(base, appName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
return &Store{path: filepath.Join(dir, "todos.json")}, nil
}
func (s *Store) Load() ([]Todo, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return []Todo{}, nil // first launch — start with an empty list
}
if err != nil {
return nil, err
}
var todos []Todo
if err := json.Unmarshal(data, &todos); err != nil {
return nil, err
}
return todos, nil
}
func (s *Store) Save(todos []Todo) error {
data, err := json.MarshalIndent(todos, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0o644)
}A missing file on first launch is a normal state, not an error, so we single out os.ErrNotExist and return an empty list. Without this distinction you get an app that shows an error notification on every first run.
startup and shutdown — hooking into the lifecycle #
Now we connect the store to the app lifecycle: load in the startup hook we saw in #3, save in the shutdown hook.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
store, err := NewStore("wails-todo")
if err == nil {
a.store = store
a.todos, _ = store.Load()
}
}
func (a *App) shutdown(ctx context.Context) {
if a.store != nil {
a.store.Save(a.todos)
}
}Register shutdown through OnShutdown on options.App. Keep in mind that shutdown may never run on a force quit or a crash, so in practice it is safer to save at every point where the data changes — on add and on delete. For data at the scale of a todo list, saving every time costs nothing you can feel.
Error handling — from a Go error to a notification #
When a bound Go method returns an error, the frontend sees a rejected Promise. That connection is the foundation of the error-handling design.
func (a *App) AddTodo(title string) ([]Todo, error) {
title = strings.TrimSpace(title)
if title == "" {
return nil, fmt.Errorf("please enter a todo item")
}
a.todos = append(a.todos, Todo{Title: title})
if err := a.store.Save(a.todos); err != nil {
return nil, fmt.Errorf("failed to save: %w", err)
}
return a.todos, nil
}try {
const todos = await AddTodo(input.value);
render(todos);
} catch (err) {
showToast(String(err)); // the error message returned by Go
}Sorting errors into two groups keeps the code organized.
- Expected errors — empty input, a malformed value: problems the user can fix. Build the message as a sentence meant for the user and return it.
- Unexpected errors — a failed disk write, a corrupted file: problems the user can do nothing about. Show the user only a short notice and send the details to the log.
Logging — preparing for life after release #
During development you can see the terminal, but a shipped app has none. Leave a file trail to look at when something goes wrong. Wails provides an option to swap in your own logger.
import "github.com/wailsapp/wails/v2/pkg/logger"
err := wails.Run(&options.App{
Title: "todo",
Logger: logger.NewFileLogger(logPath), // a path under the config directory works well
// ...
})In app code, write entries with the runtime.LogInfo(ctx, ...) and runtime.LogError(ctx, ...) family. Leave one LogError line at every point where an unexpected error can surface, and a single log file from a user is often all you need to find the cause.
External API calls belong on the Go side #
If the app needs to call an external API — weather, exchange rates — prefer calling it with Go’s net/http and exposing the result through a bound method over using fetch in the frontend. Two reasons.
- No CORS. A fetch inside the WebView is bound by the same origin rules as a browser, while Go’s HTTP client makes an ordinary program-level network request with no such constraint.
- The key stays out of sight. An API key placed in frontend code ships in plain view inside the distributed files. On the Go side it lives inside the binary, and you assemble requests the way server code would.
Wrapping up #
Three things matter most from this post.
- Store data in an app directory under
os.UserConfigDir. The same code points at the correct path on all three OSes. - A Go method’s error becomes a rejected Promise in the frontend. Errors the user can fix travel as sentences; unexpected errors go to the log.
- Putting external API calls on the Go side removes CORS problems and reduces key exposure.
The app is now functionally complete. In the next post, “Build a Desktop App with Wails #6: Build and Distribution — Packaging per Platform”, we turn it into executables and hand it to other people’s computers — the final step, and the end of the series.