WebSocket Streaming#
Misskey's real-time streaming layer is a WebSocket-based pub/sub system that connects the frontend client to the backend over a persistent socket at /streaming. It handles live timelines, notifications, emoji updates, and other event-driven features.
Key files:
| Layer | File |
|---|---|
| Core library (misskey-js) | packages/misskey-js/src/streaming.ts |
| Frontend singleton | packages/frontend/src/stream.ts |
| Boot integration | packages/frontend/src/boot/main-boot.ts |
| Boot error handling | packages/frontend/public/loader/boot.js |
| Backend server | packages/backend/src/server/ServerService.ts |
Stream Lifecycle (Frontend)#
Stream class (misskey-js)#
The core Stream class wraps reconnecting-websocket and exposes three states: 'initializing', 'reconnecting', and 'connected' .
The WebSocket URL is constructed as {wsOrigin}/streaming?i={token}&_t={timestamp}, where _t is a cache-buster . The minReconnectionDelay is set to 1 ms to work around a known reconnecting-websocket issue .
State transitions:
onOpen: sets state to'connected', emits_connected_, and if it is a reconnect, re-subscribes all active channels .onClose: transitions from'connected'→'reconnecting'and emits_disconnected_.
useStream() singleton (packages/frontend/src/stream.ts)#
useStream() is the frontend-wide factory — it creates the Stream instance on first call and returns the cached instance on subsequent calls. It also starts a heartbeat timer at a 60-second interval . The heartbeat (stream.heartbeat() sends 'h') is also triggered immediately on tab visibility change if the last send was more than 60 seconds ago .
Boot integration (main-boot.ts)#
Streaming is only initialized for logged-in users when realtimeMode is enabled . After calling useStream(), main-boot.ts sets up disconnect behavior driven by prefer.s.serverDisconnectedBehavior:
'reload'— hard-reloads the page immediately'dialog'— shows a confirmation dialog offering to reload; only one dialog at a time
The main channel (stream.useChannel('main', null, 'System')) is then opened for user-specific events like meUpdated, unreadNotification, and announcementCreated .
Channel Connection Model#
Two connection types exist in streaming.ts:
SharedConnection— backed by aPool. Multiple callers sharing the same channel (without params) share one underlying subscribe/unsubscribe. The pool disconnects after a 3-second idle timeout once all users calldispose().NonSharedConnection— used when channel params are provided. Each call creates a unique subscription .
Use stream.useChannel(channel, params?, name?) to open a connection; call .dispose() on the returned handle to close it.
On reconnection, onOpen automatically re-subscribes all sharedConnectionPools and nonSharedConnections .
Boot Error Handling (Frontend)#
The loader script boot.js installs window.onerror and window.onunhandledrejection handlers early, rendering a user-visible error screen via renderError() for any uncaught exceptions (codes like SOMETHING_HAPPENED, SOMETHING_HAPPENED_IN_PROMISE, APP_IMPORT) . These global handlers are removed after common() completes and the app is mounted , so errors during the boot window are caught but post-mount errors are handled by the Vue app.
Backend: Fastify Error Handling & Shutdown (PR #17401)#
The backend's ServerService.ts manages Fastify startup and teardown, with changes landed in PR #17401.
Startup: streamingApiServerService.attach(fastify.server) wires the raw WebSocket upgrade handler to the HTTP server . fastify.listen() is called with await inside a try/catch, so TCP and Unix socket startup failures are routed through handleListenError() instead of becoming unhandled promise rejections . The helper distinguishes three cases :
EACCES→ logs a permission-denied messageEADDRINUSE→ logs a port/socket conflict message- Default → logs the raw error
fs.chmodSync() for Unix socket permissions is applied after listen() resolves, preventing a spurious ENOENT when listen fails . fastify.ready() is also awaited inside the same block so plugin-registration timeouts are caught .
Shutdown: dispose() first calls streamingApiServerService.detach(), then races fastify.close() against a 5-second timeout . This cap is necessary because streamingApiServerService.attach() registers raw ws.Server upgrades that Fastify does not track in its connection registry — without the cap, fastify.close() can hang indefinitely, blocking PM2/systemd/Kubernetes shutdown sequences .