Guide · 15 minutes · Node.js

Cross-Device Polling vs. WebSockets

Digi-ID is a cross-device flow: the phone posts the signature to your callback, but the browser showing the QR code is the one that needs to sign in. Something has to tell the browser that the scan succeeded. You can ask repeatedly (polling) or be told (WebSockets).

This guide builds on Add Digi-ID login to a web app, which uses polling.

At a glance

Short pollingWebSocket
Delay after approvalUp to one interval (1–2 s)Near-instant
Requests per login~45 at 2 s over a 90 s challengeOne upgrade, one message
InfrastructureAny HTTP server, CDN or serverless platformLong-lived connections; proxies must allow upgrades
Multiple instancesWorks with any shared store (Redis, SQL)Needs pub/sub so the instance that got the callback can reach the socket
Setting the session cookieDirectly in the status responseNot possible over the socket; needs a follow-up HTTP request

Recommendation: start with polling. Move to WebSockets when instant feedback matters (kiosks, doors, checkout) or login volume makes polling traffic noticeable, and keep polling as the fallback.

1. Short polling

The browser asks /digiid/status every two seconds. When the callback has been verified, that same response regenerates the session and sets the cookie.

public/login.js
const poll = setInterval(async () => {
  const { state } = await fetch(`/digiid/status?nonce=${nonce}`).then((r) => r.json());
  if (state === 'verified') { clearInterval(poll); location.assign('/account'); }
  if (state === 'expired') { clearInterval(poll); start(); }
}, 2000);
  • Keep the interval at 1–2 s. Faster adds load without a noticeable gain; slower feels broken.
  • Stop polling when the tab is hidden (document.visibilityState) and resume when it returns.
  • Rate-limit the status endpoint per session, not per IP, so shared networks aren't blocked.

2. WebSocket push

The browser opens a socket for its nonce. When the callback verifies the signature, the server pushes verified to that socket only.

server.js
import { WebSocketServer } from 'ws';

const sessionMiddleware = session({ /* same options as the web-app guide */ });
app.use(sessionMiddleware);

const server = app.listen(3000);
const wss = new WebSocketServer({ noServer: true });
const sockets = new Map(); // nonce -> ws

server.on('upgrade', (req, socket, head) => {
  const url = new URL(req.url, `https://${req.headers.host}`);
  if (url.pathname !== '/digiid/ws' || req.headers.origin !== process.env.SITE_ORIGIN) return socket.destroy();

  sessionMiddleware(req, {}, () => {
    const nonce = url.searchParams.get('nonce');
    const challenge = pending.get(nonce);
    if (!challenge || challenge.sessionId !== req.sessionID) return socket.destroy();

    wss.handleUpgrade(req, socket, head, (ws) => {
      sockets.set(nonce, ws);
      ws.on('close', () => sockets.delete(nonce));
    });
  });
});

// In the callback handler, after the signature is verified:
challenge.address = address;
sockets.get(digiid.nonce)?.send(JSON.stringify({ state: 'verified' }));

The push only says that the scan succeeded. The browser then calls the normal status endpoint, which regenerates the session and sets the cookie:

public/login.js
const ws = new WebSocket(`wss://${location.host}/digiid/ws?nonce=${nonce}`);
const expiry = setTimeout(() => { ws.close(); start(); }, 90_000);

ws.onmessage = async (event) => {
  if (JSON.parse(event.data).state !== 'verified') return;
  clearTimeout(expiry);
  ws.close();
  const { state } = await fetch(`/digiid/status?nonce=${nonce}`).then((r) => r.json());
  if (state === 'verified') location.assign('/account');
};
ws.onerror = () => { clearTimeout(expiry); startPolling(nonce); }; // proxies and some networks block upgrades

3. Running more than one server

The wallet's callback can land on a different instance from the one holding the browser's socket. Keep challenges in Redis or your database, and publish a message when a callback is verified:

server.js
// Callback instance
await redis.publish('digiid:verified', digiid.nonce);

// Every instance
subscriber.subscribe('digiid:verified', (nonce) => {
  sockets.get(nonce)?.send(JSON.stringify({ state: 'verified' }));
});

Polling needs only the shared store, which is one reason it is the simpler default.

Security checklist

  • Bind to the session: only the browser session that created the challenge may poll it or open its socket.
  • Check Origin on upgrade: WebSocket handshakes aren't covered by CORS, so reject unexpected origins to prevent cross-site socket hijacking.
  • Never push the address: send a state, and let the authenticated status request finish the sign-in.
  • Close on expiry: drop sockets and polling when the challenge expires, then issue a new one.
  • Use wss:// only in production, just as callbacks must use HTTPS.

Next: Nonces & Replay Prevention.

Back to top