socket.io: `login` emitted from the `connect` callback is silently dropped (handlers registered after an `await`)

Author: legend813Created Aug 11, 2026Updated Sep 8, 2026
Labelsbug

I have found these related issues/pull requests

I searched the issue tracker for socket/login/timeout reports. The closest hits I found (#3519, #3769, #4409, #4422, #6354, #6535) all describe the browser client looping through "Lost connection to the socket server / Reconnecting" and are usually traced back to reverse proxy configuration. That is a different symptom: in the case below the connection stays perfectly healthy and only a single event is lost.

Possibly the same root cause, in a downstream wrapper and still open: lucasheld/ansible-uptime-kuma#17 — "Occasional failure to loginByToken produces hang/timeout". No mechanism is identified there. One detail differs: that reporter says the server logged a successful authentication, whereas here the login handler is never reached at all. So it may be related, or may be a second failure mode on the same path.

I did not find an existing report describing the ordering problem itself.

️ Security Policy

Description

The connection handler is async and registers its event listeners only after an await:

// server/server.js
io.on("connection", async (socket) => {
    await sendInfo(socket, true);        // line 390
    …
    socket.on("loginByToken", …)         // line 401
    …
    socket.on("login", …)                // line 450

sendInfo() awaits two database-backed settings (primaryBaseURL, server.getTimezone()), so the continuation can be deferred past an I/O turn of the event loop.

A client that emits login from its own connect callback — the natural thing to do, and what the wiki's Internal API examples suggest — can therefore win that race. When the packet arrives before socket.on("login", …) has run, socket.io has no listener for it and drops it silently: no error, no acknowledgement, no server-side log entry.

The connection itself stays fully healthy, engine.io pings keep flowing. The client only notices via its own timeout, which makes this look like a network problem even when client and server sit on the same host over 127.0.0.1.

This affects any socket.io client that authenticates immediately after connect. Browsers usually take longer to get there, which may be why it mostly shows up for API clients and scripts.

Suggested fix

Register the event listeners before the first await in the connection handler, and do the sendInfo() call afterwards. That keeps the whole registration in the synchronous part of the handler and closes the window entirely.

Workaround for client authors

Wait for the server's info event before emitting login. socket.emit("info", …) is the last statement of sendInfo(), so by the time a client receives it the registrations have run.

What I could not determine Why it is intermittent. The obvious explanation — a cold settings cache makes sendInfo() slower, so the race is lost — is contradicted by measurement: across 28 runs the info event arrived after 3 to 24 ms, for failures and successes alike. My remaining hypothesis, explicitly labelled as one: it is not the duration that decides but the kind of wait. If sendInfo() resolves purely from the settings cache, everything stays in microtasks and no incoming packet can be processed in between, so the registration always wins. If it needs a real database read, that is I/O and the login packet can be handled first. This would also explain why an immediate retry succeeds essentially always — the first attempt populated the cache. I have not measured this.

Reproduction steps

  1. npm install socket.io-client

  2. Save the script below as kuma-repro.js.

  3. Run it against any Uptime Kuma 2.5.0 instance:

    KUMA_URL=http://127.0.0.1:3001 KUMA_USER=admin KUMA_PASS=secret node kuma-repro.js 10 65
    

Variant A emits login from the connect callback, variant B waits for info first. Both run alternately in the same process so they see identical conditions.

⚠️ A pause between runs is required (the script defaults to 65 s). Back-to-back connections almost always succeed — presumably because the settings cache is still warm and sendInfo() then resolves without real I/O.

kuma-repro.js
/**
 * Minimal reproduction: a `login` emitted from the `connect` callback is
 * silently dropped, and its acknowledgement never arrives.
 *
 * Variant A emits `login` right away (what most clients do).
 * Variant B waits for the server's `info` event first.
 * Both run alternately in the same process so they see identical conditions.
 *
 * Usage:
 *   npm install socket.io-client
 *   KUMA_URL=http://127.0.0.1:3001 \
 *   KUMA_USER=admin KUMA_PASS=secret \
 *   node kuma-repro.js 10 65
 *
 * Arguments: <pairs> <pause between runs in seconds>
 * A pause is required — back-to-back runs almost always succeed.
 */
'use strict'

const { io } = require('socket.io-client')

const URL = process.env.KUMA_URL || 'http://127.0.0.1:3001'
const PAIRS = Number(process.argv[2] || 10)
const PAUSE_S = Number(process.argv[3] || 65)
const TIMEOUT_MS = 20000

if (!process.env.KUMA_USER || !process.env.KUMA_PASS) {
  console.error('KUMA_USER and KUMA_PASS must be set')
  process.exit(2)
}

/** @param {boolean} waitForInfo Variant B instead of A */
function run(nr, waitForInfo) {
  return new Promise((done) => {
    const t0 = Date.now()
    const variant = waitForInfo ? 'B wait-for-info' : 'A emit-on-connect'
    let finished = false
    let sent = null

    const socket = io(URL, { transports: ['websocket'] })

    const finish = (result) => {
      if (finished) return
      finished = true
      clearTimeout(timer)
      socket.close()
      console.log(`#${String(nr).padStart(2)} ${variant.padEnd(18)} ${result}`
        + ` after ${Date.now() - t0} ms`)
      done(result)
    }
    const timer = setTimeout(() => finish('NO ACK'), TIMEOUT_MS)

    const login = () => {
      if (sent !== null) return
      sent = Date.now() - t0
      socket.emit('login', {
        username: process.env.KUMA_USER,
        password: process.env.KUMA_PASS,
        token: '',
      }, (res) => finish(res && res.ok ? 'ok' : 'rejected'))
    }

    // `info` is the last statement of sendInfo(); the event handlers are
    // registered immediately afterwards. Waiting for it means we cannot lose.
    socket.on('info', () => { if (waitForInfo) login() })
    socket.on('connect', () => { if (!waitForInfo) login() })
    socket.on('connect_error', (e) => finish(`connect_error: ${e.message}`))
  })
}

async function main() {
  console.log(`${URL} — ${PAIRS} pairs, ${PAUSE_S}s pause, ${TIMEOUT_MS}ms timeout\n`)
  const lost = { 'A emit-on-connect': 0, 'B wait-for-info': 0 }
  for (let i = 1; i <= PAIRS; i++) {
    if (await run(i, false) === 'NO ACK') lost['A emit-on-connect']++
    await new Promise((r) => setTimeout(r, PAUSE_S * 1000))
    if (await run(i, true) === 'NO ACK') lost['B wait-for-info']++
    if (i < PAIRS) await new Promise((r) => setTimeout(r, PAUSE_S * 1000))
  }
  console.log(`\nA emit-on-connect: ${lost['A emit-on-connect']} of ${PAIRS} lost`)
  console.log(`B wait-for-info  : ${lost['B wait-for-info']} of ${PAIRS} lost`)
}

main()

My results over 14 pairs on an idle instance with 54 monitors:

Variant login acknowledgement never arrived
A — emit on connect 8 of 14
B — emit after info 0 of 14

Expected behavior

The acknowledgement callback passed to socket.emit("login", …) is always invoked — either with ok: true or with a rejection. An event emitted after the client's connect event has fired is not lost.

Actual Behavior

Intermittently the acknowledgement never arrives. The client waits until its own timeout.

The server log shows only the two lines from allowRequest and nothing else — in particular no Login by username + password, which is the first statement of the login handler. That handler was never entered.

A client-side packet trace of a failing run (timestamps relative to socket creation):

3ms engine-open > 3ms <-CONNECT > 3ms connect > 3ms ->login > 4ms <-EVENT > 25002ms <-ping

The engine.io ping at 25 s proves the connection was alive the whole time.

Uptime-Kuma Version

2.5.0

Operating System and Arch

Debian 12 (bookworm), x86_64

Browser

Not applicable — this is a socket.io API client (Node.js, socket.io-client), not the web UI.

️ Deployment Environment

  • Uptime Kuma 2.5.0 in an LXC container on Proxmox VE, Node.js, SQLite backend
  • ~54 monitors, mostly 60 s interval
  • The client runs in the same container and connects to http://127.0.0.1:3001, so there is no network, proxy or name resolution between the two processes
  • No reverse proxy involved

Relevant log output

Server, failing run (nothing follows these two lines):

    2026-08-11T19:55:13+02:00 [SOCKET] INFO: New websocket connection, IP = 127.0.0.1
    2026-08-11T19:55:13+02:00 [AUTH] INFO: WebSocket with no origin is allowed

Server, successful run for comparison:

    2026-08-11T19:56:35+02:00 [SOCKET] INFO: New websocket connection, IP = 127.0.0.1
    2026-08-11T19:56:35+02:00 [AUTH] INFO: WebSocket with no origin is allowed
    2026-08-11T19:56:35+02:00 [AUTH] INFO: Login by username + password. IP=127.0.0.1
    2026-08-11T19:56:35+02:00 [AUTH] INFO: Successfully logged in user <redacted>

Note that both log lines come from `allowRequest` in `server/uptime-kuma-server.js`
(lines 160 and 171), i.e. from the check that runs *before* the handshake. They only prove that
the upgrade request arrived, not that a session was established — which is what made this
confusing to diagnose.