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.

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:

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.

One click reaches every open tab through the server Laptop tab Nim server Phone tab 1. Page loads open /ws open /ws count 6, 2 clients count 6, 2 clients 2. Laptop clicks +1 inc UPDATE counter, now 7 push count 7 push count 7 shows 7 shows 7
On load, each tab opens /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.

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);
  };
}

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.

Try it yourself

  1. Watch the frames. Open the counter, then DevTools → Network → filter “WS”, reload and click the /ws row. The Messages tab lists every frame: the state on open, each inc you send, each state coming back.
  2. Push from outside the browser. curl -X POST https://counter.pietroppeter.xyz/api/increment updates every open tab, because the API calls the same increment().
  3. Talk to the socket by hand. With websocat: websocat wss://counter.pietroppeter.xyz/ws prints the state and counts as a client. Type inc and press Enter.
  4. 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.