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.
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.
Boot sequence
Section titled “Boot sequence”The process is deliberately fail-fast, and every fatal path prints a plain sentence naming the cause before exiting non-zero.
- Read configuration. A missing
DATABASE_URL, an unparseableBASE_URL, an unknownLOG_LEVELorAUTH_MODE, proxy trust without a validTRUSTED_PROXY_CIDRSallowlist, or a non-positive resource-limit value each stop the process here. - 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.
- 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.
- Run migrations, behind a blocking advisory lock so replicas booting at the same moment serialize rather than racing each other’s DDL.
- Serve.
Requests and WebSockets
Section titled “Requests and WebSockets”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.
| Ping interval | 25s | Server to client |
|---|---|---|
| Pong deadline | 50s | Missed, and the connection is closed |
| Write deadline | 5s | Per frame |
| Send buffer | 16 frames | Full buffer means a wedged reader — that connection is dropped rather than blocking the room |
| Presence debounce | 1.5s | Roster churn is coalesced |
| Max frame read | 4096 bytes | Clients send almost nothing; mutations go over HTTP |
| Session revalidation | at most every 30s | Checks revocation and expiry in Postgres without extending idle lifetime |
| Authentication failure close | 1008 | WebSocket 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.
Panics in background goroutines
Section titled “Panics in background goroutines”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.
| Socket write pump | one socket | The connection is closed and detached; every other socket in the room is untouched |
|---|---|---|
| Socket read pump | one socket | Same — the connection detaches on the way out |
| Hub callbacks | one callback | Validation, membership, presence and join callbacks all run here; the hub keeps serving |
| Session revalidation | one socket | Fails 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 loop | nothing — it is not guarded | Deliberate. 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 callbacks | one handshake | The 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 listener | one subscription | Treated as a dropped listener: /readyz goes 503 and it reconnects on the usual backoff |
| Presence sweeper | one pass | The next tick sweeps again |
| Plugin retention, outbox and job workers | one pass | Logged 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.
Health endpoints
Section titled “Health endpoints”| GET /healthz | always 200 | Liveness. Never touches the database |
|---|---|---|
| GET /readyz | 200 or 503 | Readiness. 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.
Shutdown
Section titled “Shutdown”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.