socket.io: `login` emitted from the `connect` callback is silently dropped (handlers registered after an `await`)
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
- I have read and agree to Uptime Kuma's 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
npm install socket.io-clientSave the script below as
kuma-repro.js.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.
/**
* 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.
Source: louislam/uptime-kuma