Server actions let you push updates from server-side code to connected clients. Use them for timers, webhooks, background job notifications, real-time data feeds, and any scenario where the server initiates a UI update.
LiveTemplate supports two types of updates:
| Type | Trigger | Scope | Use Case |
|---|---|---|---|
| Client Action | User interaction (click, submit) | Same session group | Form submissions, button clicks |
| Server Action | Server-side code | Same session group | Timers, webhooks, background jobs |
Server actions use the Session interface to trigger updates:
// From any goroutine - timer, webhook handler, background job
session.TriggerAction("notification", map[string]interface{}{
"message": "Your export is ready!",
})
type Session interface {
// TriggerAction dispatches the action to the controller,
// then sends the updated template to ALL connections for this user.
TriggerAction(action string, data map[string]interface{}) error
}
Key Points:
TriggerAction() calls your action method just like client-initiated actionsAuthenticator — see API Reference)Access the Session through the OnConnect lifecycle method on your controller:
type TimerController struct {
session livetemplate.Session
mu sync.Mutex
}
func (c *TimerController) OnConnect(state TimerState, ctx *livetemplate.Context) (TimerState, error) {
c.mu.Lock()
c.session = ctx.Session()
c.mu.Unlock()
// Start background timer
go c.runTimer(ctx)
return state, nil
}
func (c *TimerController) OnDisconnect() {
c.mu.Lock()
c.session = nil
c.mu.Unlock()
}
Lifecycle:
1. WebSocket connection established
└─► OnConnect(state, ctx) called
└─► Store ctx.Session() for later use
2. Connection active
└─► Use session.TriggerAction() from background goroutines
3. WebSocket connection closed
└─► OnDisconnect() called
└─► Clean up session reference
Context (ctx):
type TimerState struct {
Seconds int
}
type TimerController struct {
session livetemplate.Session
mu sync.Mutex
}
func (c *TimerController) OnConnect(state TimerState, ctx *livetemplate.Context) (TimerState, error) {
c.mu.Lock()
c.session = ctx.Session()
c.mu.Unlock()
// Start background timer
go c.runTimer(ctx)
return state, nil
}
func (c *TimerController) OnDisconnect() {
c.mu.Lock()
c.session = nil
c.mu.Unlock()
}
func (c *TimerController) runTimer(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return // Connection closed
case <-ticker.C:
c.mu.Lock()
session := c.session
c.mu.Unlock()
if session != nil {
session.TriggerAction("tick", nil)
}
}
}
}
func (c *TimerController) Tick(state TimerState, ctx *livetemplate.Context) (TimerState, error) {
state.Seconds++
return state, nil
}
func (c *TimerController) Reset(state TimerState, ctx *livetemplate.Context) (TimerState, error) {
state.Seconds = 0
return state, nil
}
Periodic updates (dashboards, live data, countdowns):
func (c *Controller) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
c.session = ctx.Session()
go c.runTicker(ctx)
return state, nil
}
func (c *Controller) runTicker(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if c.session != nil {
c.session.TriggerAction("refresh", nil)
}
}
}
}
func (c *Controller) Refresh(state State, ctx *livetemplate.Context) (State, error) {
state.Data = c.fetchLatestData()
return state, nil
}
External events pushing updates to users:
// HTTP handler receives webhook from external service
func handleWebhook(w http.ResponseWriter, r *http.Request) {
var payload WebhookPayload
json.NewDecoder(r.Body).Decode(&payload)
// Get session for target user (stored during OnConnect)
if session := getUserSession(payload.UserID); session != nil {
session.TriggerAction("notification", map[string]interface{}{
"message": payload.Message,
"type": "webhook",
})
}
w.WriteHeader(http.StatusOK)
}
Greet users after page loads:
func (c *AuthController) OnConnect(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
c.session = ctx.Session()
if state.IsLoggedIn {
// Send welcome after short delay (let page render first)
go func() {
time.Sleep(500 * time.Millisecond)
if c.session != nil {
c.session.TriggerAction("serverWelcome", map[string]interface{}{
"message": fmt.Sprintf("Welcome back, %s!", state.Username),
})
}
}()
}
return state, nil
}
func (c *AuthController) ServerWelcome(state AuthState, ctx *livetemplate.Context) (AuthState, error) {
state.WelcomeMessage = ctx.GetString("message")
return state, nil
}
Notify users when async jobs finish. Use proper cleanup with context cancellation:
type ExportState struct {
ExportStatus string
}
type ExportController struct {
session livetemplate.Session
cancelExport context.CancelFunc
mu sync.Mutex
}
func (c *ExportController) OnConnect(state ExportState, ctx *livetemplate.Context) (ExportState, error) {
c.mu.Lock()
c.session = ctx.Session()
c.mu.Unlock()
return state, nil
}
func (c *ExportController) OnDisconnect() {
c.mu.Lock()
defer c.mu.Unlock()
// Cancel any running export when user disconnects
if c.cancelExport != nil {
c.cancelExport()
c.cancelExport = nil
}
c.session = nil
}
func (c *ExportController) StartExport(state ExportState, ctx *livetemplate.Context) (ExportState, error) {
// Create cancellable context for the background job
jobCtx, cancel := context.WithCancel(context.Background())
c.mu.Lock()
c.cancelExport = cancel
c.mu.Unlock()
go func() {
defer cancel() // Clean up when done
result, err := performLongRunningExport(jobCtx)
// Check if cancelled before notifying
select {
case <-jobCtx.Done():
return // User disconnected, don't notify
default:
}
c.mu.Lock()
session := c.session
c.mu.Unlock()
if session != nil {
if err != nil {
session.TriggerAction("exportFailed", map[string]interface{}{
"error": err.Error(),
})
} else {
session.TriggerAction("exportComplete", map[string]interface{}{
"downloadURL": result.URL,
})
}
}
}()
state.ExportStatus = "Processing..."
return state, nil
}
func (c *ExportController) ExportComplete(state ExportState, ctx *livetemplate.Context) (ExportState, error) {
state.ExportStatus = "Complete"
state.DownloadURL = ctx.GetString("downloadURL")
return state, nil
}
func (c *ExportController) ExportFailed(state ExportState, ctx *livetemplate.Context) (ExportState, error) {
state.ExportStatus = "Failed: " + ctx.GetString("error")
return state, nil
}
Push notifications from any part of your application:
// Global session registry (thread-safe)
var userSessions = sync.Map{}
func (c *Controller) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
c.session = ctx.Session()
userSessions.Store(ctx.UserID(), c.session)
return state, nil
}
func (c *Controller) OnDisconnect() {
userSessions.Delete(c.userID)
c.session = nil
}
// Call from anywhere in your application
func NotifyUser(userID string, message string) {
if session, ok := userSessions.Load(userID); ok {
session.(livetemplate.Session).TriggerAction("notification", map[string]interface{}{
"message": message,
})
}
}
Session methods are thread-safe and can be called from any goroutine:
// Safe: Multiple goroutines using session concurrently
go func() { session.TriggerAction("update1", nil) }()
go func() { session.TriggerAction("update2", nil) }()
However, you must protect access to the session field itself:
type Controller struct {
session livetemplate.Session
mu sync.Mutex
}
func (c *Controller) OnConnect(state State, ctx *livetemplate.Context) (State, error) {
c.mu.Lock()
c.session = ctx.Session()
c.mu.Unlock()
return state, nil
}
func (c *Controller) OnDisconnect() {
c.mu.Lock()
c.session = nil
c.mu.Unlock()
}
func (c *Controller) triggerFromBackground() {
c.mu.Lock()
session := c.session
c.mu.Unlock()
if session != nil {
session.TriggerAction("update", nil)
}
}
Session is scoped to the current user only:
TriggerAction() affects ALL connections for THIS userWhy this design?
When a user has multiple tabs or devices connected:
Client Action (from Tab 1):
User clicks button in Tab 1
└─► Tab 1's action method called
└─► Tab 1 receives update
└─► Tab 2, Tab 3 receive Sync() dispatch (if controller implements Sync)
When the controller implements a
Sync()method, other tabs automatically receive a Sync dispatch after each action. WithoutSync(), usectx.BroadcastAction("ActionName", nil)for explicit cross-tab sync.
Server Action (TriggerAction):
Background job completes
└─► session.TriggerAction("jobComplete", data)
└─► ALL tabs receive the action via action method
└─► ALL tabs are updated simultaneously
In multi-instance deployments, TriggerAction() automatically publishes to Redis so all instances can update their local connections. See the PubSub Reference for setup, channel schema, and subscription lifecycle.