Live counter with WebSockets
How a small web counter keeps every open browser tab in sync with WebSockets, walked through with its actual Nim and JavaScript code.
The app
The app is a click counter: a page with a big number and a +1 button. Everyone who opens it sees the same number, and a click adds one for everybody. It is deliberately tiny, a playground for learning how to deploy a small app on your own server.
- Nim, compiled to a single binary.
- Mummy, a multi-threaded HTTP server for Nim with WebSockets built in.
- SQLite, holding the count in one row of one table.
- Disco, a self-hosted deploy tool: a push to
mainbuilds the Dockerfile on a small VPS and serves the app over HTTPS.
This page describes the code at commit a5a584d. The repository may have moved on since.
The problem
In the first version, the number was baked into the page when it loaded. With the counter open on a laptop and a phone, a click on the laptop did not show up on the phone until you refreshed it. Each click was also a form submission that reloaded the whole page.
Keeping every tab in agreement without refreshing needs the server to tell browsers that something changed. Browsers normally only hear from a server when they ask, so this needs a different kind of connection: a WebSocket.
WebSockets in two minutes
A WebSocket is a connection the browser keeps open, so the server can send a message whenever it wants. Plain HTTP can't do that: the server only ever answers a request.
Plain HTTP (how the first version worked). The browser asks, the server answers, and the exchange is over. GET / returns the page with the count baked in. The server has no way to tell your phone that the laptop just clicked; the phone only finds out when it asks again, which is your refresh.
WebSocket. The browser opens one long-lived, two-way channel. It starts as an ordinary HTTP request with a special header:
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
The server answers 101 Switching Protocols. From then on the same TCP connection stops speaking HTTP and carries small frames in both directions: text, binary, ping, pong or close. Either side can send at any moment. That is exactly what live sync needs: when anyone clicks, the server pushes the new number down every open connection.
The alternatives not picked:
- Polling. The page asks
/api/countevery few seconds. Simple, but updates lag by up to the interval, and every open tab keeps sending requests even when nothing changes. - Server-Sent Events (SSE). A one-way stream from the server over a long-lived HTTP response. It would fit too, but Mummy 0.4.8 has no streaming responses. WebSockets are built into Mummy.
A bonus of a two-way channel: the +1 click travels up the same socket, so the page no longer reloads.
The big picture
Every tab holds a socket to the server, and the server sends each new state down all of them.
/ws and gets the current state. A click sends inc up the laptop's socket; the server updates SQLite and pushes the new count to both tabs, including the one that clicked.The server: counter.nim
The server remembers who is connected and sends the current state to all of them whenever something changes. Here are the parts that matter, in order.
1. A set of connected clients
import std/[os, strutils, locks, sets]
...
var
db: DbConn
dbLock: Lock
clients: HashSet[WebSocket] # guarded by dbLock too, so pushes stay in order
Each open browser tab is one WebSocket value in this set. A connection is added when it opens and removed when it closes.
Why the lock matters. Mummy runs handlers on several worker threads at once, and changing a HashSet from two threads at the same time corrupts it. A Lock lets only one thread inside at a time. One lock, dbLock, guards both the database and the set, for the reason in step 2. The {.gcsafe.}: blocks tell the Nim compiler “this touches a global from a thread; the lock makes it safe”.
2. Pushing the state
proc pushState(count: int) =
## Sends the count and the number of connected browsers to all of them.
## Callers must hold dbLock.
{.gcsafe.}:
let msg = """{"count":""" & $count & ""","clients":""" & $clients.len & "}"
for client in clients:
client.send(msg)
proc increment(): int =
withLock dbLock:
{.gcsafe.}:
result = db.getValue(sql"UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value").parseInt
pushState(result)
Each message is a small JSON object, for example {"count":7,"clients":2}. After the database update, it goes to every client as a text frame. The button, the plain form POST and /api/increment all call increment(), so every path triggers the push.
Updating and sending under the same lock keeps the order right. Without it, two clicks could commit 6 then 7 but send 7 then 6, and a phone would end up showing 6. client.send only queues the frame, so holding the lock while sending is cheap.
3. The /ws endpoint
proc wsUpgradeHandler(request: Request) =
discard request.upgradeToWebSocket()
...
router.get("/ws", wsUpgradeHandler)
This is the handshake from the primer. upgradeToWebSocket() checks the Upgrade headers and replies 101. From then on Mummy calls the WebSocket handler for that connection instead of HTTP handlers.
4. The WebSocket handler
proc websocketHandler(websocket: WebSocket, event: WebSocketEvent, message: Message) =
case event
of OpenEvent:
withLock dbLock:
{.gcsafe.}:
clients.incl(websocket)
pushState(db.getValue(sql"SELECT value FROM counter WHERE id = 1").parseInt)
of MessageEvent:
if message.kind == TextMessage and message.data == "inc":
discard increment()
of ErrorEvent:
discard
of CloseEvent:
withLock dbLock:
{.gcsafe.}:
clients.excl(websocket)
pushState(db.getValue(sql"SELECT value FROM counter WHERE id = 1").parseInt)
Mummy calls one proc for everything that happens on any socket; event says what happened.
OpenEvent: a tab connected. Add it and push the state to everyone: the new tab catches up on the count, and the others see one more client. Both steps happen under the lock, so no increment can slip in between and be missed.MessageEvent: the browser sent something. The only message accepted is the textinc, meaning “+1 please”.ErrorEvent: nothing to do; a close always follows.CloseEvent: the tab went away. Remove it and push, so the others see one client fewer.
5. The heartbeat
proc heartbeat() {.thread.} =
while true:
sleep(25_000)
withLock dbLock:
{.gcsafe.}:
for client in clients:
client.send("", Ping)
A background thread sends a WebSocket ping to every client every 25 seconds. Browsers answer with a pong automatically. A silent connection can look dead to proxies, routers and mobile networks, and some cut it after a minute or so; the ping keeps a little traffic flowing.
6. Wiring it up
const clientJs = staticRead("client.js")
...
var heartbeatThread: Thread[void]
createThread(heartbeatThread, heartbeat)
let server = newServer(router, websocketHandler)
The heartbeat thread starts, and newServer gets the WebSocket handler as its second argument. The browser script lives in its own client.js for editing and syntax highlighting; staticRead embeds it into the binary at compile time, so the app is still a single file to deploy.
Browser side: client.js
About 25 lines keep a socket open, show pushed state, and send clicks up the socket.
Connecting
const n = document.getElementById("n"), s = document.getElementById("s");
let ws, timer, delay = 500;
function connect() {
ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
new WebSocket(url) starts the handshake. ws:// and wss:// are the WebSocket versions of http:// and https://. Browsers refuse an unencrypted socket from an HTTPS page, so the script picks wss:// on the live site and ws:// on localhost.
The three callbacks
ws.onopen = () => { delay = 500; s.textContent = "live"; s.className = "s on"; };
ws.onmessage = (e) => {
const m = JSON.parse(e.data); // {"count": 7, "clients": 2}
n.textContent = m.count;
s.textContent = "live (" + m.clients + ")";
};
ws.onclose = () => {
s.textContent = "reconnecting"; s.className = "s";
timer = setTimeout(connect, delay); delay = Math.min(delay * 2, 10000);
};
}
onopen: connected. Show “live” in green and reset the retry delay.onmessage: the server pushed a frame. Parse the JSON, show the count and “live (N)”. This handler is the whole “sync”.onclose: the connection dropped. Show “reconnecting” and retry afterdelay, which doubles each failure (0.5 s, 1 s, 2 s… capped at 10 s). This exponential backoff stops a hundred tabs from hammering a server that is down for a redeploy.
There is no onerror handler: a failed connection always fires onclose too, and the retry lives there.
Sending the click, with a fallback
document.getElementById("f").onsubmit = (e) => {
if (ws && ws.readyState === WebSocket.OPEN) { e.preventDefault(); ws.send("inc"); }
};
The button sits inside a plain <form method="post" action="/increment">. When the socket is open, e.preventDefault() cancels the form submission (which would reload the page) and ws.send("inc") sends the click instead. The new count comes back through onmessage, the same way it reaches every other tab. Without a socket or without JavaScript, the form submits as before, so the button always works.
Waking up on phones
document.addEventListener("visibilitychange", () => {
if (!document.hidden && ws.readyState === WebSocket.CLOSED) { clearTimeout(timer); delay = 500; connect(); }
});
connect();
Mobile browsers often kill sockets of background tabs, and the backoff may have grown to 10 s. When the tab becomes visible again, the script cancels the pending retry and reconnects right away; the server pushes the state on open, so the number is fresh as soon as you look. The last line opens the first connection.
Running it on Disco
WebSockets need no special Disco configuration: disco.json is the same as for a plain HTTP app. Four things are worth knowing.
- The proxy passes WebSockets through. Disco puts Caddy in front of the app for HTTPS. Caddy sees
Upgrade: websocketand forwards the connection as is:wss://from the browser to Caddy, plainws://from Caddy to the app on port 8000. - Every deploy drops all sockets. A deploy replaces the container, so open tabs show “reconnecting” for about a second, then catch up.
- One container only. The
clientsset lives in one process's memory. With two replicas, a click on replica A would only reach tabs connected to A; fixing that needs a shared message bus. Disco runs one replica by default. - “Clients” means open tabs. Two tabs on the same laptop count as 2.
Try it yourself
- Watch the frames. Open the counter, then DevTools → Network → filter “WS”, reload and click the
/wsrow. The Messages tab lists every frame: the state on open, eachincyou send, each state coming back. - Push from outside the browser.
curl -X POST https://counter.pietroppeter.xyz/api/incrementupdates every open tab, because the API calls the sameincrement(). - Talk to the socket by hand. With websocat:
websocat wss://counter.pietroppeter.xyz/wsprints the state and counts as a client. Typeincand press Enter. - Break the connection. Put the phone in airplane mode for a few seconds. The status goes to “reconnecting”, then back to “live (N)” with the latest count.