Skip to content

Architecture

Verified against v0.10.0 · cmd/parley/main.go, internal/api/router.go, internal/hub/hub.go

Parley is one process and one database.

The React frontend is compiled and embedded into the Go binary, so the same process serves the HTML, the API, and the WebSocket. There is no second service to deploy, no Node runtime in production, and no way for the frontend and backend to be at different versions.

BrowserHTTPS + WSReverse proxyTLS, timeoutsparleyany replica, SPA embeddedper-pod socket hubrooms, presencePostgresall state

All durable state is in Postgres — including presence and the room-code attempt throttle. The only thing held in the process is the WebSocket hub: the set of sockets this instance is holding. Fanout between instances goes through Postgres LISTEN/NOTIFY, so nothing depends on one process seeing every connection, and Parley runs on as many replicas as you like.

The process is deliberately fail-fast, and every fatal path prints a plain sentence naming the cause before exiting non-zero.

  1. Read configuration. A missing DATABASE_URL, an unparseable BASE_URL, an unknown LOG_LEVEL or AUTH_MODE, proxy trust without a valid TRUSTED_PROXY_CIDRS allowlist, or a non-positive resource-limit value each stop the process here.
  2. Start the JSON logger and print the boot settings line — base URL, cookie security, allowed WebSocket origin, port, auth mode, proxy trust. This line is the fastest way to confirm what an instance actually thinks its configuration is.
  3. Connect to Postgres — up to six attempts, with doubling backoff between them and a 5-second timeout on each, so roughly a minute before it gives up. This is what makes starting alongside a database container work.
  4. Run migrations, behind a blocking advisory lock so replicas booting at the same moment serialize rather than racing each other’s DDL.
  5. Serve.

A browser loads the SPA, calls the JSON API under /api, and opens one WebSocket per session it is watching. The socket carries the whole session state on attach and again after every mutation — there are no incremental patches.

That design is why redaction lives in the serializer rather than in a handler: a client is sent a view built for it, so an unrevealed vote is absent from the payload rather than hidden by the frontend.

WebSocket lifecycle
Ping interval25sServer to client
Pong deadline50sMissed, and the connection is closed
Write deadline5sPer frame
Send buffer16 framesFull buffer means a wedged reader — that connection is dropped rather than blocking the room
Presence debounce1.5sRoster churn is coalesced
Max frame read4096 bytesClients send almost nothing; mutations go over HTTP
Session revalidationat most every 30sChecks revocation and expiry in Postgres without extending idle lifetime
Authentication failure close1008WebSocket policy violation; logout disconnects synchronously here and is fanned out to the other replicas over NOTIFY

The send-buffer rule is the important one. One laptop on bad hotel wifi must never be able to freeze everyone else’s board, so a connection that cannot keep up is dropped and told to reconnect.

An HTTP handler that panics returns 500 and nothing else happens: chi’s recoverer catches it. That covers requests only, and most of what a replica does is not a request. Every long-lived goroutine therefore recovers its own panics.

What recovers, and what a recovered panic costs
Socket write pumpone socketThe connection is closed and detached; every other socket in the room is untouched
Socket read pumpone socketSame — the connection detaches on the way out
Hub callbacksone callbackValidation, membership, presence and join callbacks all run here; the hub keeps serving
Session revalidationone socketFails closed. The panic is reported as a failed validation, so the connection is closed with 1008 rather than being left holding access nothing can re-check
Hub owner loopnothing — it is not guardedDeliberate. It calls no application code: every callback it reaches for is handed to a tracked goroutine first, so a panic here would be a bug in the hub itself, and continuing on corrupted room state is worse than restarting the replica
Attach-path callbacksone handshakeThe join and facilitator-seen calls made while a socket is being attached run on the request goroutine, so chi's recoverer covers them; that handshake is abandoned and the client reconnects
Fanout listenerone subscriptionTreated as a dropped listener: /readyz goes 503 and it reconnects on the usual backoff
Presence sweeperone passThe next tick sweeps again
Plugin retention, outbox and job workersone passLogged like any failed pass and retried on the next tick

A recovered panic is logged at error with the goroutine that raised it and a full stack, so it looks like this (pretty-printed here; the real line is one JSON object):

{
"time": "2026-01-01T12:00:00Z",
"level": "ERROR",
"msg": "recovered a panic in a background goroutine",
"goroutine": "hub write pump",
"panic": "runtime error: invalid memory address or nil pointer dereference",
"stack": "goroutine 42 [running]:\nruntime/debug.Stack()\n...",
"session": "01J...",
"user": "01J..."
}

A recovered panic must never leave a socket with more access than an ordinary failure would. That is why revalidation fails closed: the periodic membership re-check is what closes the sockets of somebody removed from a space on another replica, and a panic that merely ended the loop would leave that connection alive and unchecked for as long as the client kept it open.

The one gap the recovery cannot close is timing. A panic in the fanout listener is a reconnect, and Postgres does not queue notifications for a session that is not listening — so a revocation published during those few seconds is simply gone. The reconnect resyncs room state, not authorization, and the backstop is the per-connection revalidation tick: worst case, a socket revoked in that window survives one revalidation interval (30s) longer than it otherwise would.

GET /healthzalways 200Liveness. Never touches the database
GET /readyz200 or 503Readiness. Pings Postgres with a 3s timeout, then checks this replica's Postgres LISTEN is up — 503 with 'not listening for session changes' if it is not

The container image’s own HEALTHCHECK runs parley -healthcheck, which fetches /readyz with a 3-second timeout and exits 0 or 1. It probes loopback when BIND_ADDR is empty, 127.0.0.1, or localhost; otherwise it probes BIND_ADDR.

On SIGINT or SIGTERM the hub synchronously closes its WebSockets, then the server stops accepting new connections and is given 10 seconds to finish in-flight HTTP requests. Clients see the socket drop and reconnect, which is what the reconnect banner in the UI is for. The Kubernetes manifest allows 30 seconds of termination grace to cover the sequence.