Sync engine

Push + pull + conflicts.

8 minhard

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

┌─────────────────────────────────────────────────┐ │ ┌────────┐ │ │ │ loop │ every 30s + on reconnect │ │ └───┬────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────┐ │ │ │ online? no → wait │ │ │ └─────┬────────────────┘ │ │ │ yes │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ squash outbox │ reduce churn │ │ └─────────┬───────────┘ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ POST /api/sync/push│ send local changes │ │ │ {batch:[…]} │ │ │ └─────────┬───────────┘ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ delete sent rows │ from outbox │ │ │ mark sale.ServerID │ │ │ └─────────┬───────────┘ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ GET /api/sync/pull │ fetch server changes │ │ │ ?since=<cursor> │ since last pull │ │ └─────────┬───────────┘ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ apply to local DB │ conflict? → resolve │ │ │ update cursor │ │ │ └─────────────────────┘ │ │ │ └─────────────────────────────────────────────────┘
Runs every 30s + on reconnect. Push first, then pull. Conflicts surface to the user; never auto-overwrite their work.

The push call

sync/engine.go
func (e *Engine) Push(ctx context.Context) error {
e.Squash()
var entries []OutboxEntry
e.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 successes
for _, 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
}
// Pull
func (e *Engine) Pull(ctx context.Context) error {
var cur Cursor
e.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 DB
e.db.Save(&row)
}
cur.Value = res.NextCursor
e.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.
Never silently merge financial data. Sales, invoices, refunds — if the server says $100 and the client says $120, picking either silently is wrong. Surface the conflict; let the user decide.

The Wails wiring

app.go (excerpt)
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// Background sync loop — every 30s, and on online events
go 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

Two cashiers edit the same product offline. Cashier A changes the price to $10; Cashier B changes the description. They both sync. What's the safest behaviour?

Try it

For the chapter assignment, simulate the full offline → online cycle:

  1. Disconnect from the internet (turn off Wi-Fi).
  2. Record 3 notes in your app.
  3. Watch the "pending sync" counter — should be 3.
  4. Reconnect to the internet.
  5. Within 30 seconds (or after clicking Sync), the counter drops to 0.
  6. 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