Outbox pattern

Queue mutations, sync later.

9 minmedium

The outbox is a tiny table that says "these rows haven't been sent to the server yet". When you write a sale locally, you also write an outbox row. When the sync engine runs (next lesson), it drains the outbox.

The pattern

Outbox write flow

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ │ │ React calls RecordSale(items) │ │ │ │ │ │ │ ā–¼ │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ │ │ BEGIN TRANSACTION │ │ │ ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ │ │ │ INSERT INTO sales │ ◄── business │ │ ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ │ │ │ INSERT INTO outbox │ ◄── sync intent │ │ │ (entity='sale', │ │ │ │ entity_id=<uuid>, │ │ │ │ op='create') │ │ │ ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ │ │ │ COMMIT │ │ │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ │ │ │ │ ā–¼ │ │ Return sale to React │ │ (sync happens later, in background) │ │ │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
Transactionally: the business write + the outbox entry happen together. Either both succeed or both fail.

The outbox model

internal/models/outbox.go
type OutboxEntry struct {
ID int64 `gorm:"primaryKey;autoIncrement"` // local-only
Entity string `gorm:"not null;index"` // 'sale', 'product', …
EntityID string `gorm:"not null;index"` // the business-row UUID
Operation string `gorm:"not null"` // 'create' | 'update' | 'delete'
Payload datatypes.JSON // the row, serialised
Attempts int `gorm:"default:0"`
LastError string
CreatedAt time.Time
}

Notice: ID is auto-increment here (local-only) — gives a clean FIFO order. EntityID is the UUID of the actual business row (a Sale, a Product), used to look up the latest state when syncing.

Writing through the outbox

internal/service/sales.go
func (s *Service) RecordSale(sale *Sale) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(sale).Error; err != nil {
return err
}
payload, _ := json.Marshal(sale)
return tx.Create(&OutboxEntry{
Entity: "sale",
EntityID: sale.ID,
Operation: "create",
Payload: payload,
}).Error
})
}

Both inserts in one transaction. If either fails, both roll back. You can never have a sale without a matching outbox entry, or vice versa.

The unsynced indicator

For UX, show the user what hasn't synced yet:

func (s *Service) PendingCount() (int64, error) {
var n int64
err := s.db.Model(&OutboxEntry{}).Where("attempts < 10").Count(&n).Error
return n, err
}
function SyncStatus() {
const { data } = useQuery({
queryKey: ['pending'],
queryFn: () => PendingCount(),
refetchInterval: 3000,
})
return data === 0
? <span className="text-green-500">āœ“ synced</span>
: <span className="text-yellow-500">{data} pending sync</span>
}

Cashier glances at the corner of the screen, knows whether they're caught up.

Compacting + squashing

If a product gets updated 50 times offline, do you really need 50 outbox rows? No — only the final state matters. A compaction step squashes the trail:

Before squash: After squash:
outbox: product update 1 outbox: product update (latest)
outbox: product update 2
outbox: product update 3
outbox: product update 4

For create-then-delete, both rows can be dropped — the server never needed to know. Grit ships a squash function; call it before sync.

Don't delete outbox rows on success without a marker. If the sync confirms but the local commit fails, the outbox row sticks around and re-sends next time. That's the desired behaviour for at-least-once delivery. The server-side handler must be idempotent (use the EntityID for dedup).

Quick check

A sale write succeeded but the outbox insert failed (disk full). What's the right behaviour?

Try it

Add the outbox to your Note workflow:

  1. Add the OutboxEntry model + AutoMigrate it.
  2. Wrap your AddNote service in a transaction that inserts both the note AND the outbox entry.
  3. Wire a PendingCount method bound to React. Show the count in the corner.
  4. Add 5 notes. Confirm the count goes from 0 → 1 → 2 → … → 5.

What's next

Outbox is filling. Last lesson of the chapter — the sync engine that drains it.

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