You are reading the docs for v0.9. Read the latest (v0.10)
Troubleshooting
Each section is a symptom as you would see it: a status, a body, a log line or a panic. The quoted text is what via prints.
On this page
- Every action answers 403 forbidden origin
- The page never updates live
- Clicks answer 410 Gone
- A Disconnected banner, or the tab keeps reloading
- OnConnect or OnDispose never runs
- Mount panics at startup
- A page answers 500 render failed
- Rows keep the wrong state after a delete or reorder
- An upload answers 413 request body too large
- Connects or actions answer 503
- Error: Blocked by CSP
- Copy to clipboard does nothing
#Every action answers 403 forbidden origin
403 forbidden originvia.WithTrustedOrigin turned the origin check on and the browser's request fails it. The allowlist compares scheme, host and port, so the usual misses are:
- A different spelling:
http://forhttps://,www., a non-default port. - A sibling subdomain: its
Sec-Fetch-Siteissame-site, which fails. List it. - A proxy that strips
Sec-Fetch-*or rewritesHostto the upstream address. nginx needsproxy_set_header Host $host;. - An empty origin read from the environment, like the
VIA_ORIGINthe Deploy page's main.go reads, so the listed value is not the site's.
func newRouter() *via.Router {
return via.NewRouter(
// Exactly as the browser sends it: scheme, host, port if not default.
via.WithTrustedOrigin("https://example.com"),
via.WithTrustedOrigin("https://app.example.com"),
)
}
A 403 with the body session mismatch is a different check: a live action carried a different session than the one its tab connected with. The cookie expired or was cleared, or another app on the same host overwrote it; give each app its own via.WithSessionCookieName. A bare forbidden is your OnInit or OnReload returning via.ErrForbidden.
See Sessions & security: Origin checks and CSRF.
#The page never updates live
Clicks POST and answer 200, or a Tick runs on the server, and the page does not change. Check in this order:
- No live unit. A page streams only if a unit renders a
via.Stateorvia.List, or registersvia.Ctx.Tickorvia.Ctx.ListeninOnInit. A page of Signals is plain: an action's response patches it, and nothing else can. - The proxy buffers the stream. Caddy needs
flush_interval -1on thereverse_proxy; nginx needsproxy_buffering off;. Compression is not the cause with Caddy'sencodeor Traefik'scompress: both flush each frame. - An idle timeout under 25 s. via writes a keepalive every 25 seconds, fixed. A proxy or balancer that closes a silent connection sooner drops every quiet stream; 60 s is enough.
- The State sits behind a closed branch. The GET rendered no State, so the page was served plain, and the action that opens the branch fails:
500 action failed
level=ERROR msg="via: action panic" err="via: this action made a unit live on a page that was served plain — the tab has no stream. Every action after this one would 410. Liveness must not depend on a branch an action can open: render the State unconditionally, or register a Tick/Listen in OnInit so the page is live from the first render"Render the State outside the via.When, or register the Tick or Listen in OnInit so the unit is live from the first render.
See Deploy.
#Clicks answer 410 Gone
410 no such action cGAmF8Xg; this render does not bind it
410 this render does not bind that action for that argument
410 no such child
410 this tab id has no open stream for this page: the connection closed, or the id belongs to a page that is gone — reload
410 this action needs the page's tab id and the request carried no viatab signal: the page never opened its stream, or the id was filtered outlevel=WARN msg="via: no such action; this render binds others" act=cGAmF8Xg binds="Of-LgGbQ (main.(*Todos).Delete-fm)"
level=WARN msg="via: the action is bound, but not for this arg" act=main.(*Todos).Delete-fm arg="\"2\"" bound=1An action is dispatchable only if the render before it bound that handler, with that argument, in that child. A 410 means the render the server last ran no longer contains what the client clicked: the button sits inside a via.When that is now closed, the on.Bind argument changed since the page was drawn, or the via.Child moved. The two stream messages are a live unit's action with no open stream behind it.
- Bind a stable key as the argument, a row's primary key, never a position, count or cursor.
- Gate
via.Whenon session or database state, and keep any When around avia.Childfixed for the life of the page: it shifts later siblings' keys. - On a plain page, a handler that exists only inside a branch opened by a bound Signal always 410s: the render that authorises runs before the posted signals apply. Move the handler outside the branch.
The browser reloads the tab on its own after a 410, with a "Page is out of date - reloading…" banner. An action for a live tab whose stream is still connecting, or reconnecting, waits up to 2 seconds (less if via.WithPinnedDeadline is shorter) for it, and answers 410 only if the stream has not come by then. When via ended the stream itself, the 410 comes at once.
See Actions: Arguments.
#A Disconnected banner, or the tab keeps reloading
via's reconnect script shows a banner at the top of the page. What each state means:
| Banner | Cause | What happens next |
|---|---|---|
| Reconnecting… | The network dropped the stream. | The browser retries; the banner clears on the next patch. |
| Disconnected. | The stream ended cleanly: a deploy, a restart, Router.Close. | Probes the page URL with backoff (0.5 s to 8 s) and reloads once it answers. |
| Page is out of date - reloading… | A request answered 410. | Probes, then reloads. |
| Disconnected., and it stays | The stream's connect answered 403; or two automatic reloads already; or 20 probes failed. | Nothing until you press Reconnect, which clears the reload count. |
Every banner but Reconnecting… carries a Reconnect button that probes at once. An action that fails with the stream still up (a 500 from a handler panic, a 503, a 403) shows no banner and leaves data-via-connection alone; Datastar fires a datastar-fetch error event on the element that made the request, for a message of your own.
A banner right after every deploy is expected; state that lives only in a via.State starts over, since a reconnect is a new connection. A Disconnected banner that stays on first load is a stream connect answering 403, or a 5xx on every reload: read the Network tab for the status and find it on this page. The reload count clears 5 seconds after a load, so a server that fails straight after every reload stops at two.
See Live state: Connection lifecycle.
#OnConnect or OnDispose never runs
func (r *Room) OnInit(ctx *via.Ctx) error {
ctx.OnConnect(r.join)
ctx.OnDispose(r.leave)
return nil
}
func (r *Room) join() { online.Add(1) }
func (r *Room) leave() { online.Add(-1) }
Besides View and PageMeta, v0.8 calls two methods: OnInit and OnReload. A v0.7 OnConnect(ctx *via.Ctx) error or OnDispose(ctx *via.Ctx) method still compiles and is never called. Mount warns once about the OnConnect; the OnDispose has an action's shape, so via says nothing. Register both from OnInit with via.Ctx.OnConnect and via.Ctx.OnDispose. They run only on a unit something else made live; on a plain unit there is no connection to open or close.
level=WARN msg="via: main.Room.OnConnect is shaped like the v0.7 OnConnect hook, which v0.8 never calls. Move its work into OnInit: ctx.OnConnect(fn) runs fn once when the stream opens, and ctx.Tick or ctx.Listen makes the unit live."A method named like a hook with the hook's exact signature, on a type that lacks the hook, warns once. That covers aliases (Init, Reload, Refresh, Metadata) and names one typo from a hook (OnRelaod, Oninit); OnRelay and OnInput are left alone.
level=WARN msg="via: main.Room.Oninit looks like a mis-named OnInit — it has the hook's exact signature but main.Room has no OnInit func(*via.Ctx) error, so nothing will ever call it. Rename it to OnInit."See Migrating from v0.7.
#Mount panics at startup
panic: via: main.Room.OnInit has signature func(*via.Ctx), not func(*via.Ctx) error — so the hook will never run
panic: via: main.Search.View has a VALUE receiver and the composition holds Signals — View must take a POINTER receiver (func (p *Search) View() h.H), or every rendered Signal binds against a discarded copy
panic: via: Mount path "/files/{path...}": a {name...} wildcard is not supported — a page's action and stream routes live under its pathRegistration mistakes panic when the router is built, before it serves: a hook with the wrong signature, a View with a value receiver on a type holding Signals, a {name...} wildcard or a reserved path segment, a session key under 16 bytes, two mounts on one path. The message names the type or the pattern; fix that and restart.
via.Mount also renders the mounted value once, without running OnInit, and panics on a wiring mistake that render reaches: a func literal bound once per row, a method taken through an interface field or an ambiguous value receiver, h.El("script"), a Signal with no slot, a child without a View. Those messages start found at Mount. Any other panic in that render is ignored, since the page has no data yet. A mistake behind a branch the empty value does not take panics at the first render that takes it.
#via.Child given a pointer
panic: via: found at Mount, rendering *main.Page as mounted: via: via.Child(child) requires child to have a View() methodvia.Child takes the child by value and calls View on its own copy, so via.Child(&p.Chat) hands it a **Chat. Pass the field: via.Child(p.Chat).
See Compositions.
#A page answers 500 render failed
A composition mistake that only a render with data can see, or that Mount's render did not reach. The body is always render failed; the log line says which.
#A live unit inside another live unit
500 render failed
level=ERROR msg="via: render panic" err="via: via.Child: a live unit cannot sit inside another live unit — embed it directly from a plain ancestor instead"A live unit may not contain another live unit, and a live child's View may not call via.Child at all. Keep the live units as siblings under a plain parent:
type Dashboard struct {
Chat Chat
Clock Clock
Feed Feed
}
func (d *Dashboard) View() h.H {
return h.Main(via.Child(d.Chat), via.Child(d.Clock), via.Child(d.Feed))
}
See Compositions.
#Rows keep the wrong state after a delete or reorder
func (l *Todos) row(t Todo) h.H {
return h.Li(h.ID("todo-"+strconv.Itoa(t.ID)),
h.Input(h.Type("checkbox")),
h.Str(t.Title),
h.Button(on.Click(on.Bind(l.Delete, t.ID)), h.Str("delete")),
)
}
func (l *Todos) View() h.H { return h.Ul(via.Each(l.Items, l.row)) }
Datastar morphs a re-rendered list by position. Delete the second of three rows without ids and the browser keeps the second element and rewrites its text, so a ticked checkbox, typed input or open <details> now belongs to the row below. A stable id per row makes the morph match by id. The click itself is not affected: on.Bind carries the row's key.
See Actions: Arguments.
#An upload answers 413 request body too large
413 request body too largeTwo caps: via.WithMaxBody (1 MiB) for a JSON action body, and via.WithMaxUpload (8 MiB) for the whole multipart body of a via.PostForm submit. Raise the one that matches; a proxy in front may have its own limit, such as nginx's client_max_body_size.
func newUploadRouter() *via.Router {
return via.NewRouter(
via.WithMaxUpload(64<<20), // a PostForm submit's whole body
via.WithMaxBody(4<<20), // a JSON action body
)
}
See Sessions & security › Request limits.
#Connects or actions answer 503
503 stream capacity reached
level=WARN msg="via: refusing an SSE connect with 503: the router is at its live-stream cap. There is no per-IP share of that cap, so a single client can hold all of it; every tab is refused until one closes. The limit is WithMaxSSEConn — raise it only with the memory to back it." open=10000 cap=10000
503 stream busy
level=WARN msg="via: tab pinned — its stream goroutine is inside a Tick, Listen, OnConnect or action handler, so its keepalives have stopped too and every action on this tab answers 503 until it returns. Move blocking work off the handler."stream capacity reached is the router at via.WithMaxSSEConn (10,000 open streams). The cap is router-wide with no per-client share; raise it only with the memory for one goroutine and one composition tree per stream.
stream busy is one tab's goroutine stuck in a handler for longer than via.WithPinnedDeadline (5 s). Tick, Listen and actions on a tab run one at a time, so a blocking call in any of them stalls the rest. Do slow work in a goroutine and publish the result back through a topic. When the line says blocked writing a frame to a client that is not reading instead, no handler is at fault: the browser stopped reading, and the stream closes once the 10 s write deadline passes. A 503 with session store unavailable is a via.SessionStore that failed to load.
See Sessions & security › Request limits.
#Error: Blocked by CSP
Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-...') is required to enable inline execution.
Uncaught Error: Blocked by CSP.Datastar compiles each expression by adding a script that carries the page's nonce, and via's own policy admits it. The error means a second policy reached the browser, from middleware or the proxy (nginx add_header, Caddy header), and browsers enforce every policy they receive. Remove the extra script-src, or build it from via's header so it carries the same nonce; 'unsafe-eval' does not help. Asset origins belong in PageMeta().Assets, which via folds into its own policy.
#EvalError: Refused to evaluate a string as JavaScript
EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of scriptA script of yours, or a library it loads, calls eval, new Function or setTimeout with a string. via's policy forbids it. Use a build of the library that does not evaluate strings, or, if there is none, set via.WithUnsafeEval, which allows it on every page.
See Sessions & security › Content-Security-Policy.
#Copy to clipboard does nothing
expr.CopyToClipboard and expr.CopyTextOf call navigator.clipboard.writeText and swallow a refused write, so it fails with nothing in the console. Browsers allow it in a secure context (HTTPS, or localhost), from a user gesture, in a focused document. Over plain HTTP on a LAN address navigator.clipboard is undefined and the call throws; from on.LoadCS or a background tab the write is refused. Bind it to on.ClickCS and serve over HTTPS.
See Signals: Expressions.