WebSockets
RomM uses socket.io for real-time communication over two endpoints:
| Endpoint | Purpose |
|---|---|
/ws/socket.io |
Live updates (scans, notifications, sync, activity, installs, streaming, log streaming), plus the /devices namespace |
/netplay/socket.io |
Netplay session coordination (room discovery, join/leave, WebRTC signalling) |
Authentication
The default namespace on /ws/socket.io and the netplay endpoint authenticate with the browser's session cookie, and the handshake binds the socket to the login session in romm_session. A socket without a valid session still connects, but it joins no per-user room, so it never receives events addressed to a user, and the events that act on a user's behalf (scan, activity:*) are rejected or ignored.
Signing out, or revoking a session, disconnects every socket that session opened, on both endpoints and on every worker, and the client has to sign in again before it reconnects.
The /devices namespace
Companion apps that hold a device-bound Client API Token connect to this namespace instead, passing the token in the handshake's auth payload or in an Authorization: Bearer header:
const socket = io("https://demo.romm.app/devices", {
path: "/ws/socket.io",
transports: ["websocket"],
auth: { token: "rmm_..." },
});
The handshake is refused with unauthorized unless the token is live, bound to a device, and holds devices.read (as does its owner), and with disabled when DEVICE_INSTALL_ENABLED=false. While connected, the device counts as online for GET /api/devices/online, until the socket closes because the token expired or was revoked, or the device was deleted.
Events
Scans
Client to server, both requiring the tasks.run scope:
| Event | Payload |
|---|---|
scan |
A ScanPayload object, the same body POST /api/tasks/scan takes |
scan:stop |
None. Cancels queued scans and stops the running one |
ScanPayload rejects unknown keys, so a misspelt option fails instead of silently scanning the whole library. Every field is optional:
{
"type": "quick",
"platforms": [12, 34],
"platform_fs_slugs": [],
"roms_ids": [],
"apis": ["igdb", "ss", "hasheous"],
"launchbox_remote_enabled": true
}
| Field | Default | Meaning |
|---|---|---|
type |
quick |
One of new_platforms, quick, update, unmatched, complete, hashes |
platforms |
[] (all) |
Platform ids to scan |
platform_fs_slugs |
[] |
Platform folder names to scan |
roms_ids |
[] |
Scan only these ROMs. Such a scan may queue while a library scan runs, and goes ahead of any that's waiting |
apis |
every enabled provider | Metadata providers to use, by source key: igdb, moby, ss, ra, launchbox, hasheous, tgdb, sgdb, flashpoint, hltb, demozoo, pouet, csdb, steam, gamelist, libretro, playmatch |
launchbox_remote_enabled |
true |
Let LaunchBox use its remote API as well as the local metadata store |
Server to client, broadcast to every connected socket:
| Event | Payload |
|---|---|
scan:scanning_platform |
The platform being scanned |
scan:scanning_rom |
The ROM just scanned, as a SimpleRomSchema without its file list, plus is_new (true when this scan added it) |
scan:update_stats |
Running totals for the scan |
scan:done |
Final stats |
scan:done_ko |
An error message string |
scan:done_ko is also sent, only to the socket that asked, when a scan event couldn't start. That happens when the caller lacks tasks.run, when the payload fails validation (the message lists each invalid field), when no scan worker is running, or when a library scan is already queued or running.
Notifications
Sent to every open tab of the user they belong to (see Notifications):
| Event | Payload |
|---|---|
notifications:new |
The new notification, as GET /api/notifications returns it |
notifications:read |
{"ids": [...]}, or {"ids": null} when all were marked read |
notifications:dismissed |
{"ids": [...]}, or {"ids": null} when all were dismissed |
Activity
The "now playing" feed. Clients report their own sessions, and the acting user always comes from the socket's session, never from the payload:
| Event | Direction | Payload |
|---|---|---|
activity:start |
Client to server | {"rom_id": 123, "device_id": "..."} |
activity:heartbeat |
Client to server | Same as activity:start, to keep the session alive |
activity:stop |
Client to server | Same as activity:start |
activity:update |
Server to client | An activity entry, sent only to users who can see the ROM |
activity:clear |
Server to client | {"user_id", "device_id", "rom_id"}, sent to the same audience |
activity:refresh |
Server to client | {}. Refetch GET /api/activity, because the session's audience couldn't be resolved |
activity:update reaches only the user:{id} rooms of users allowed to see the ROM, which takes hidden entities and age limits into account. When that audience can't be resolved, activity:refresh goes to everyone instead, coalesced across workers so a burst of failures produces one refresh.
Device sync
Sent to the user's tabs while a device syncs (see Device Sync Protocol). Every payload carries device_id and session_id:
| Event | Extra fields |
|---|---|
sync:started |
sync_mode |
sync:progress |
operations_completed, operations_planned, current_file |
sync:completed |
operations_completed, operations_failed |
sync:conflict |
file_name, rom_id, rom_name, reason |
sync:error |
error |
Device installs
| Event | Sent to | Payload |
|---|---|---|
install:updated |
The owner's tabs, on the default namespace | The full install request: id, user_id, device_id, rom_id, file_ids, status, reason, created_at, updated_at |
install:queued |
The device, on /devices |
{"id", "rom_id"}. A request is waiting, so claim it with POST /api/devices/{device_id}/installs/claim |
install:cancelled |
The device, on /devices |
{"id", "rom_id"}. Drop the request, even if the download already started |
status is one of pending, taken, done, already_installed, failed or cancelled.
Permissions and logs
| Event | Sent to | Payload |
|---|---|---|
permissions:changed |
Every socket | {"user_id": 123}. The named user should refetch GET /api/permissions/me |
logs:entry |
Admins only | One backend log line, for the live log viewer (off with DISABLE_LOGS_VIEWER) |
Emulator streaming
Sent to the user's tabs while an emulator streaming session runs. Each payload names the platform, the container, and the claimed_at timestamp of the claim it belongs to, so a tab can ignore events meant for an older claim of the same container:
| Event | Extra fields |
|---|---|
streaming:launch-phase |
phase, the broker's extraction phase |
streaming:launch-ready |
host, resume, and the RetroArch core and core_tier the session booted |
streaming:launch-failed |
detail, plus refusals and refusals_truncated when the broker refused an imported save or state |
streaming:session-ended |
ended_by, reason, ended_at, rom_id, rom_name and desktop |
Netplay
Netplay runs on its own endpoint, /netplay/socket.io, so a client there can never address the per-user or admin rooms of the main endpoint. RomM's player connects to it over the WebSocket transport only, because long polling breaks when gunicorn runs more than one worker.
| Event | Who may send it |
|---|---|
open-room |
A signed-in user with roms.read who can see the ROM named by extra.game_id (a ROM id) |
join-room |
For a room with a password, anyone who sends the right one. For a room without, the same rule as open-room |
leave-room, webrtc-signal, data-message, snapshot, input |
Players already in the room |
The room password travels as a top-level password field beside extra, which is where EmulatorJS sends it:
{
"extra": {
"sessionid": "a1b2c3",
"userid": "player-1",
"room_name": "Friday co-op",
"game_id": "123",
"player_name": "alice"
},
"maxPlayers": 2,
"password": "hunter2"
}
Both events answer through the socket.io acknowledgement with an error string, such as Not authorized to open a room for this game or Incorrect password, when they fail. Over REST, GET /api/netplay/list?game_id=<rom id> lists a game's open rooms, requiring roms.read and returning 404 for a ROM the caller can't see.
Reverse-proxy requirements
Your proxy must forward the WebSocket upgrade, which all the recipes in Reverse Proxy do by default. The main UI falls back to HTTP long polling when the upgrade fails, but polling only works with a single gunicorn worker (WEB_SERVER_CONCURRENCY=1), since with the default of 4 a request can land on a worker that never saw the handshake. Netplay has no fallback at all and stops working outright.
Common breakages:
- Nginx without
proxy_set_header Upgrade $http_upgradeandConnection "upgrade" - Cloudflare with WebSockets disabled in Network settings
- Traefik without the default passthrough middlewares
A broken WS typically shows up as HTTP 400 on the upgrade request plus a flood of WebSocket connection failed errors in the browser console. Authentication Troubleshooting → WebSockets covers the diagnosis.