Desktop outbox
Queue + push + pull.
Desktop users keep the app open for hours. They expect to bookmark something, close the lid, open it tomorrow on a flight, and have everything "just work". The outbox pattern is how you deliver on that promise.
What an outbox is
Outbox flow
The schema
package dbimport "time"type OutboxItem struct {ID uint `gorm:"primaryKey"`Op string // "create_bookmark", "delete_bookmark"Payload string // JSONStatus string // "pending", "done", "failed"Attempts intLastError stringCreatedAt time.TimeUpdatedAt time.Time}
Enqueue on every mutation
func (s *BookmarkService) Create(productID uint) error {// 1. Apply locally so the UI is instantlocal := models.Bookmark{ProductID: productID, UserID: s.currentUserID()}if err := s.db.Create(&local).Error; err != nil {return err}// 2. Enqueue the server syncpayload, _ := json.Marshal(map[string]any{"product_id": productID})return s.db.Create(&db.OutboxItem{Op: "create_bookmark", Payload: string(payload), Status: "pending",}).Error}
Critical detail: BOTH the local insert and the outbox enqueue happen in the same DB. Wrap them in a transaction so they commit atomically β either both, or neither. Otherwise you can "create a bookmark" that never reaches the server.
The drain worker
func (w *OutboxWorker) Tick(ctx context.Context) error {var items []db.OutboxItemw.db.Where("status = ?", "pending").Order("id ASC").Limit(20).Find(&items)for _, item := range items {err := w.send(ctx, item)if err == nil {w.db.Model(&item).Updates(map[string]any{"status": "done", "updated_at": time.Now(),})continue}item.Attempts++item.LastError = err.Error()if item.Attempts >= 5 {item.Status = "failed" // dead letter}w.db.Save(&item)}return nil}func (w *OutboxWorker) Run(ctx context.Context) {t := time.NewTicker(10 * time.Second)defer t.Stop()for {select {case <-ctx.Done(): returncase <-t.C: _ = w.Tick(ctx)}}}
Every 10 seconds the worker picks up 20 items, tries to send them, marks done or retries. Five strikes and it's dead-lettered.
Network awareness
The Tick should bail fast if offline β no point hammering the network stack. Wails apps can detect connectivity from the Go side:
func (w *OutboxWorker) isOnline() bool {ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()req, _ := http.NewRequestWithContext(ctx, "HEAD", w.apiURL+"/healthz", nil)resp, err := http.DefaultClient.Do(req)if err != nil { return false }defer resp.Body.Close()return resp.StatusCode == 200}
Idempotency-Key, have the API dedupe. Without this, retries cause duplicates. Don't skip.Showing queue state in the UI
<div className="flex items-center gap-2 text-xs text-text-muted"><span className={cn('h-2 w-2 rounded-full', queueCount === 0 ? 'bg-emerald-500' : 'bg-amber-500')} />{queueCount === 0 ? 'All synced' : queueCount + ' pending sync'}</div>
Tiny status indicator in the status bar. Users want to know if their work is safely sent. A green dot is reassuring.
What about reads?
Reads come from the local SQLite cache directly β no outbox needed. A separate puller pings the API every few minutes and updates the local cache. That's the next lesson's topic when we talk about conflicts: what happens when the server has data the local cache doesn't (or vice versa)?
Quick check
Try it
Build the outbox end to end:
- Add the OutboxItem model + auto-migrate.
- Wrap your local Create/Delete bookmark logic with the outbox enqueue.
- Wire the worker to run via
app.OnStartup. - Add an idempotency key (UUID) to every payload.
- Test: disable network, bookmark 3 products. Force-kill the app, re-launch online β confirm all 3 bookmarks show up server-side within 15s.
- Stress test: disable network, bookmark something, KILL THE PROCESS (not graceful shutdown). Reopen online β bookmark still syncs.
What's next
Next lesson β Conflict resolution. Mobile edits a bookmark while desktop has the old version. Whose change wins? We'll set the policy.
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