go 142 lines · 3 tabs

Optimistic UI Rollback in Go With a Compensating Action Log

Shared by codesnips Aug 2026
3 tabs
package optimistic

import (
	"errors"
	"sync"
)

type Action struct {
	ID         string
	Apply      func() error
	Compensate func() error
}

type ActionLog struct {
	mu      sync.Mutex
	applied []Action
}

func (l *ActionLog) Do(a Action) error {
	if err := a.Apply(); err != nil {
		return err
	}
	l.mu.Lock()
	l.applied = append(l.applied, a)
	l.mu.Unlock()
	return nil
}

func (l *ActionLog) Rollback() error {
	l.mu.Lock()
	pending := l.applied
	l.applied = nil
	l.mu.Unlock()

	var errs []error
	for i := len(pending) - 1; i >= 0; i-- {
		if err := pending[i].Compensate(); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}
3 files · go Explain with highlit

This snippet demonstrates the server side of an optimistic UI flow: the client mutates a resource immediately and the backend applies the change speculatively, keeping a durable trail of how to undo it if a later step fails. The pattern is a lightweight saga — every forward action carries a paired compensating action, so recovery is deterministic instead of ad-hoc.

In action_log.go, the core abstraction is Action, a pair of Apply and Compensate closures plus an ID. The ActionLog is an append-only, mutex-guarded stack of applied actions. Do runs Apply and, only on success, pushes the action so it can be reversed later. Rollback walks the log in reverse (LIFO) invoking each Compensate, which matters because compensations must unwind in the opposite order they were applied — reversing a later write before an earlier one avoids leaving the store in a half-consistent state. Compensation errors are collected with errors.Join rather than aborting, since best-effort unwind is usually preferable to stopping midway and leaking partial state.

store.go provides the resource being mutated. Store holds versioned Item values, and Update implements optimistic concurrency: the caller passes the expectedVersion, and a mismatch returns ErrVersionConflict. This version check is what makes the optimistic UI safe — if two clients raced, the stale write is rejected instead of silently clobbering. The returned prev value is captured so a compensating action can restore it exactly.

handler.go ties it together. OptimisticUpdate builds an Action whose Apply performs the versioned Update and whose Compensate restores the previous snapshot. It runs the update through log.Do, then simulates a downstream dependency via notifyDownstream; if that fails, log.Rollback reverses everything applied in the request and the handler returns 409 or 502 so the client can revert its optimistic render.

The key trade-off is that compensation is logical, not transactional — between apply and compensate another actor could observe the intermediate state, so this suits UI-level consistency rather than strict financial invariants. It shines when a mutation spans several independent systems that lack a shared transaction, and the expectedVersion guard keeps concurrent optimistic writes from corrupting each other.


Related snips

Share this code

Here's the card — post it anywhere.

Optimistic UI Rollback in Go With a Compensating Action Log — share card
Link copied