Sync engine
Push + pull + conflicts.
The sync engine drains the outbox to the server, pulls down server changes, and handles conflicts. This lesson covers the loop + the conflict-resolution policy you'll choose for your project.
The loop
Sync engine loop
The push call
func (e *Engine) Push(ctx context.Context) error {e.Squash()var entries []OutboxEntrye.db.Order("id ASC").Limit(100).Find(&entries)if len(entries) == 0 { return nil }res, err := e.client.Post("/api/sync/push", entries)if err != nil {return fmt.Errorf("network: %w", err)}// Per-entry result lets us track partial successesfor _, r := range res.Results {if r.Status == "ok" {e.db.Delete(&OutboxEntry{ID: r.LocalID})e.db.Model(&Sale{}).Where("id = ?", r.EntityID).Update("server_id", r.ServerID)} else if r.Status == "conflict" {e.handleConflict(r)} else {e.db.Model(&OutboxEntry{ID: r.LocalID}).Update("attempts", gorm.Expr("attempts + 1"))e.db.Model(&OutboxEntry{ID: r.LocalID}).Update("last_error", r.Error)}}return nil}
The pull cursor
For pull, the server needs to know "changes since when". Grit uses a per-device cursor (a server-side timestamp or sequence number) stored in a one-row Cursor table:
type Cursor struct {Entity string `gorm:"primaryKey"` // 'sale'Value string // server-issued cursor}// Pullfunc (e *Engine) Pull(ctx context.Context) error {var cur Cursore.db.First(&cur, "entity = 'sale'")res, err := e.client.Get("/api/sync/pull?since=" + cur.Value)if err != nil { return err }for _, row := range res.Changes {// Upsert into local DBe.db.Save(&row)}cur.Value = res.NextCursore.db.Save(&cur)return nil}
Conflict resolution — the three strategies
- Last-write-wins (LWW) — newer timestamp clobbers older. Simple, lossy. Fine for non-financial data (settings, activity logs).
- Server-wins — local change is dropped if the server's version is newer. Lossy on the client side. Use when the server is the source of truth (inventory authoritative).
- Manual resolution — mark the row
has_conflict=true, surface in UI for the user to choose. The right answer for financial / customer-facing data.
The Wails wiring
func (a *App) startup(ctx context.Context) {a.ctx = ctx// Background sync loop — every 30s, and on online eventsgo a.sync.Loop(ctx)}// Bound for the React side to force a sync (button press)func (a *App) SyncNow() error {if err := a.sync.Push(a.ctx); err != nil { return err }return a.sync.Pull(a.ctx)}
Auto-runs in the background; the user can also press a Sync button to force one. Both call the same engine.
Quick check
Try it
For the chapter assignment, simulate the full offline → online cycle:
- Disconnect from the internet (turn off Wi-Fi).
- Record 3 notes in your app.
- Watch the "pending sync" counter — should be 3.
- Reconnect to the internet.
- Within 30 seconds (or after clicking Sync), the counter drops to 0.
- Check your server — the 3 notes should appear.
Paste a video or screenshot sequence in notes.md.
What's next
Chapter 3 — Frameless window UI. Custom titlebar, drag regions, the polish that makes a Wails app feel native.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub