Request lifecycle: how a page renders
This walks through what happens when a browser asks Talkyard for a topic page — from the HTTP request, through the three-level render cache, out to the assets and uploads the page references, and finally the WebSocket that streams live updates. It also shows which back-end services (Postgres, Redis) get touched at each step. Facts are as of v1.2026.003, taken from the running server source (RenderedPageHtmlDao.scala, server-locations.conf, and the pub-sub actor).
Stock Talkyard publishes the web container's ports 80 and 443 directly on the host; you can optionally place your own reverse proxy in front of it (for TLS, HTTP/3, extra vhosts, etc.), which is why the first participant below is drawn as optional.
sequenceDiagram
autonumber
actor B as **Browser**
participant C as **reverse proxy**<br/>(optional)
participant W as **web**<br/>(nginx)
participant A as **app**<br/>(:9000)
participant R as **cache**<br/>(Redis)
participant P as **rdb**<br/>(PostgreSQL)
Note over B,P: 1. the page itself — GET /-123/some-topic
B->>W: HTTPS :443 / HTTP :80 (via optional reverse proxy)
Note over W: nginx limit_req / limit_conn ·<br/>Lua bandwidth accounting
W->>A: proxy_pass → app:9000
Note over A: site lookup by hostname ·<br/>session cookie check
alt Level 1 — page HTML in JVM mem-cache
Note over A: mem-cache hit — fastest path, no DB touched
else mem-cache miss
A->>P: load page + posts + users → build store JSON
A->>P: check page_html DB cache (content-hash compare)
alt Level 3 — DB cache fresh
Note over A: reuse DB-cached HTML
else DB cache stale
Note over A: serve stale HTML NOW · re-render in background<br/>(Nashorn) upserts DB cache and EVICTS mem-cache entry<br/>next request repopulates mem-cache [7UWS21]
else DB cache miss
Note over A: React SSR in-JVM (Nashorn), synchronous
A->>P: upsert rendered HTML into page_html cache
end
end
Note over A: per-user volatile JSON spliced into the<br/>HTML on every request, even cache hits
A->>R: async presence write (logged-in): userIsActive →<br/>markUserOnlineRemoveStranger
A-->>W: 200 HTML
W-->>B: 200 HTML
Note over B,P: 2. assets and uploads referenced by the page
B->>W: GET /-/assets/v…/… (Talkyard JS/CSS)
W-->>B: served from image disk (alias /opt/talkyard/assets/)<br/>— app not involved
B->>W: GET /-/u/‹pubSiteId›/‹hashPath› (avatar, attachment)
W->>A: auth_request subrequest →<br/>/-/_int_req/may-download-file/…
A-->>W: 200 (allowed)
W-->>B: file bytes from pub-files volume (ro)<br/>Cache-Control 30 d
Note over B,R: 3. live updates after load
B->>W: WebSocket upgrade /-/websocket
W->>A: proxy_pass → app:9000/-/websocket
Note over A,R: connect does NOT mark the user online<br/>(auto-reconnects ≠ human activity) — presence comes<br/>from HTTP activity in step 1 · disconnect → markUserOffline
Note over A: socket held open — new posts pushed by the<br/>in-JVM PubSub actor (Redis is NOT a pub-sub bus)
A-->>B: live updates over the open WebSocket
Step 1 — the page itself. The request lands on web (nginx), which applies native limit_req/limit_conn rate/connection limits and Lua-based bandwidth accounting, then proxy_passes to app on port 9000. The app resolves the site by hostname and checks the session cookie before it looks at any cache.
The three-level render cache is the heart of this diagram:
- Level 1 — in-JVM memory cache. Keyed by (site, page, render params). A hit returns the page HTML immediately and touches no database at all — the fast path. (It's consulted only for default-view requests: the page exists, no query params, default page root, no cache-bypass. Anything else skips straight to the DB-cache chain under a stricter rate limit.)
- Level 2 — build "store JSON" from Postgres. On a mem-cache miss, the app loads the page, its posts, and the referenced users from Postgres and assembles the "store JSON" that the page is rendered from.
- Level 3 — the
page_htmlDB cache in Postgres. The app compares a content hash against the cached HTML row. Fresh → reuse it. Stale → serve the stale HTML right now and re-render in the background; the background render (Nashorn React SSR) upserts the fresh HTML into the DB cache and evicts the Level-1 mem-cache entry, so the next request repopulates memory[7UWS21]. Miss → render synchronously via in-JVM Nashorn React SSR, then upsert the result into thepage_htmlcache.
Regardless of which cache level answered, a small blob of per-user "volatile JSON" (your unread counts, permissions, etc.) is spliced into the HTML on every request — even a Level-1 hit — so cached HTML stays shared while per-user data stays fresh. For logged-in users the app also does an async presence write to Redis (userIsActive → markUserOnlineRemoveStranger).
Step 2 — assets and uploads. The page references two kinds of static content, handled differently:
/-/assets/…(Talkyard's own JS/CSS) is served straight from disk inside the web image (alias /opt/talkyard/assets/) — the app is never involved./-/u/‹pubSiteId›/‹hashPath›(avatars, attachments) is served by web directly from thepub-filesvolume (the app never streams the bytes), but each request first passes an nginxauth_requestsubrequest toapp(/-/_int_req/may-download-file/…) to authorize the download. Only the legacy no-site-id path skips that check. Served bytes get a 30-dayCache-Control.
Step 3 — live updates. After load, the browser opens a WebSocket (/-/websocket), proxied through web to app. Two subtleties worth knowing:
- Connecting does not mark you online. The
UserConnectedhandler deliberately skips presence, because auto-reconnects are not human activity — your online status comes from real HTTP activity in step 1. A disconnect does callmarkUserOffline. - Redis is not a pub-sub bus here. New posts are fanned out to the open sockets by an in-JVM PubSub actor; Redis is used only for presence/session state, not for message delivery.
Takeaway: a warm page is answered entirely from the in-JVM memory cache with no database round-trips; the Postgres page_html cache and stale-while-revalidate re-render exist so that the expensive React server-side render (Nashorn) happens as rarely as possible, and never blocks a reader when a merely-stale copy is available.