Progress Bar

Show real server-side progress without polling. Start sets Running and spawns a goroutine that pushes the percentage every 500ms with session.TriggerAction("updateProgress", …), climbing 10% at a time until the UpdateProgress action hits 100%, flips to Done, and emits a success flash. Progress and Done are persisted so a finished run survives a brief reconnect, while Running is intentionally not — a stale spinner with no goroutine behind it is the failure mode to avoid.

Progress Bar

A background goroutine ticks progress from 10% to 100% via WebSocket pushes — no polling.

Template

A native <progress> element bound to .Progress, shown whenever the job is running or done. The success FlashTag lives inside the {{if .Done}} branch so it renders in the same pass that completes the job, and the button label switches between Start Job and Run Again.

{{define "content"}}
<article>
    <h3>Progress Bar</h3>
    <p><small>A background goroutine ticks progress from 10% to 100% via WebSocket pushes — no polling.</small></p>
    {{if or .Running .Done}}
    <progress value="{{.Progress}}" max="100"></progress>
    <p><small>{{.Progress}}% complete</small></p>
    {{end}}
    {{if .Done}}
    {{/* FlashTag must live inside the .Done branch: UpdateProgress emits
         the flash only when transitioning to Done, so they always render
         together. Flashes are ephemeral (consumed on first render) — moving
         this outside the branch would cause the flash to be consumed during
         a Running or idle render before Done is reached, and the user would
         never see it. */}}
    {{.lvt.FlashTag "success"}}
    <form method="POST">
        <button name="start">Run Again</button>
    </form>
    {{else if not .Running}}
    <form method="POST">
        <button name="start">Start Job</button>
    </form>
    {{end}}
</article>
{{end}}

progress-bar.tmpl

Handler & state

Start guards against re-entrancy, then spawnTicker runs the bounded loop; tickWithRetry retries each push for ~5s so a brief mobile background doesn't drop a tick. UpdateProgress writes the value and finalizes at 100%.

// ProgressBarController drives a bounded goroutine that ticks progress from
// 10% to 100% in 10% increments every 500ms. session.TriggerAction is
// retried for ~5 seconds per tick when the session group has zero
// connections, so brief mobile backgrounding (iOS app-switch under the
// client's 3s visibility-reconnect threshold) doesn't lose ticks. The
// retry budget is per-tick — a tick that never succeeds blocks for ~5s,
// so the goroutine's worst-case lifetime under a permanent disconnect is
// (progressTickRate + progressRetryWindow) × ceil((100-Progress)/progressStep),
// bounded at ~55s for the full 10-tick run. The next Mount returns non-Running
// state (Running is intentionally not persisted) and the user sees a
// clean Start Job button.
//
// No Mount-driven revival: a second goroutine spawned by Mount while the
// retrying goroutine was still alive caused racing UpdateProgress writes
// (one goroutine sets Done=true, the trailing one overwrites Progress
// with a mid-flight value, producing impossible "Run Again at 70%" UI).
// Likewise no OnConnect: the framework's restorePersistedState already
// loads Progress/Done from the session-group store on every reconnect,
// so manual hydration would be redundant and would re-introduce the
// same race window.
//
// UpdateProgress also guards on state.Done as defense in depth.
type ProgressBarController struct{}

// Retry attempt count derives from window/delay so the ~5s total stays
// consistent if either is tuned later. Both Duration operands are
// constants and Go allows int(typedConst), so the whole trio remains
// const — keeps the values immutable from test code.
const (
	progressStep        = 10
	progressTickRate    = 500 * time.Millisecond
	progressRetryDelay  = 100 * time.Millisecond
	progressRetryWindow = 5 * time.Second

	progressRetryAttempts = int(progressRetryWindow / progressRetryDelay)
)

func (c *ProgressBarController) Start(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) {
	if state.Running {
		return state, nil
	}
	session := ctx.Session()
	if session == nil {
		return state, nil
	}
	state.Running = true
	state.Done = false
	state.Progress = 0
	c.spawnTicker(session)
	return state, nil
}

func (c *ProgressBarController) UpdateProgress(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) {
	// Guard against stale ticks from a goroutine that was overtaken by a
	// faster one (e.g. multi-tab race). Without this, a trailing goroutine
	// could overwrite Progress to a mid-flight value AFTER another goroutine
	// already set Done=true, producing an impossible "Run Again at 70%" UI.
	if state.Done {
		return state, nil
	}
	state.Progress = ctx.GetInt("progress")
	if state.Progress >= 100 {
		state.Running = false
		state.Done = true
		ctx.SetFlash("success", "Job complete", livetemplate.FlashExpiry(flashSuccessExpiry))
		nudgeFlashExpiry(ctx, flashSuccessExpiry)
	}
	return state, nil
}

func (c *ProgressBarController) Refresh(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) {
	return state, nil
}

// spawnTicker drives state.Progress 10..100. tickWithRetry survives a
// ~5s window of dead WebSocket so brief mobile backgrounds don't end
// the run; if the connection comes back within that window the timer
// resumes seamlessly.
func (c *ProgressBarController) spawnTicker(session livetemplate.Session) {
	go func() {
		for i := progressStep; i <= 100; i += progressStep {
			time.Sleep(progressTickRate)
			if err := tickWithRetry(session, i); err != nil {
				return
			}
		}
	}()
}

func tickWithRetry(session livetemplate.Session, progress int) error {
	var lastErr error
	for attempt := 0; attempt < progressRetryAttempts; attempt++ {
		if attempt > 0 {
			time.Sleep(progressRetryDelay)
		}
		err := session.TriggerAction("updateProgress", map[string]any{
			"progress": progress,
		})
		if err == nil {
			return nil
		}
		lastErr = err
	}
	return lastErr
}

func progressBarHandler() http.Handler {
	tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/loading/progress-bar.tmpl")
	return tmpl.Handle(&ProgressBarController{}, livetemplate.AsState(&ProgressBarState{
		Title:    "Progress Bar",
		Category: "Loading & Progress",
	}))
}

handlers_loading.go:131-244

// ProgressBarState holds the state for the Progress Bar pattern (#15).
//
// Progress and Done are persisted so a completed run's outcome
// survives a brief reconnect (e.g. mobile app-switching). Running is
// NOT persisted: a stale Running=true with no goroutine to advance it
// would leave the UI on aria-busy forever, the failure mode Pattern
// #31 explicitly avoids. The ticker goroutine retries TriggerAction
// for a bounded window before exiting, so brief backgrounds (<~5s)
// are recovered by the goroutine itself; longer disconnects exit and
// the user sees a clean "Start Job" button via the persisted but
// non-Running state.
type ProgressBarState struct {
	Title    string
	Category string
	Progress int `lvt:"persist"`
	Running  bool
	Done     bool `lvt:"persist"`
}

state_loading.go:15-33

When to use

Reach for Async Operations when the work has no measurable progress and you only need loading / success / error, or Lazy Loading when content just needs to arrive after the first paint.

source: livetemplate/docs · path: examples/patterns/templates/loading/progress-bar.tmpl