Desktop outbox

Queue + push + pull.

9 minhard

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

User clicks "Bookmark" (offline) β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Local SQLite β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ bookmarks (cache) │◄┼──── update for instant UI β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ outbox │◄┼──── enqueue: POST /api/bookmarks β”‚ β”‚ (id, op, payload, β”‚ β”‚ β”‚ β”‚ status, attempts) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ (later, online) β–Ό Worker drains outbox β†’ HTTP POST β†’ API β”‚ 200 β”‚ 4xx/5xx mark done β”‚ retry / dead-letter
Writes go to a local SQLite outbox first. A worker drains it to the server. Restart-safe, retry-safe.

The schema

apps/desktop/internal/db/outbox.go
package db
import "time"
type OutboxItem struct {
ID uint `gorm:"primaryKey"`
Op string // "create_bookmark", "delete_bookmark"
Payload string // JSON
Status string // "pending", "done", "failed"
Attempts int
LastError string
CreatedAt time.Time
UpdatedAt time.Time
}

Enqueue on every mutation

apps/desktop/internal/services/bookmark_service.go
func (s *BookmarkService) Create(productID uint) error {
// 1. Apply locally so the UI is instant
local := models.Bookmark{ProductID: productID, UserID: s.currentUserID()}
if err := s.db.Create(&local).Error; err != nil {
return err
}
// 2. Enqueue the server sync
payload, _ := 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

apps/desktop/internal/sync/outbox_worker.go
func (w *OutboxWorker) Tick(ctx context.Context) error {
var items []db.OutboxItem
w.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(): return
case <-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 keys. If the worker sends a POST but the network drops before the response comes back, it'll retry β€” and create a duplicate bookmark. Solution: generate a UUID on the desktop, send it as 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

The user creates a bookmark while offline. The desktop crashes before the worker syncs. The user reopens the app online. What happens?

Try it

Build the outbox end to end:

  1. Add the OutboxItem model + auto-migrate.
  2. Wrap your local Create/Delete bookmark logic with the outbox enqueue.
  3. Wire the worker to run via app.OnStartup.
  4. Add an idempotency key (UUID) to every payload.
  5. Test: disable network, bookmark 3 products. Force-kill the app, re-launch online β€” confirm all 3 bookmarks show up server-side within 15s.
  6. 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