Skip to content

feat: fan out socket.io events across instances via Redis adapter - #352

Merged
nGervasyuk merged 4 commits into
masterfrom
feat/socketio-redis-adapter
Jul 18, 2026
Merged

feat: fan out socket.io events across instances via Redis adapter#352
nGervasyuk merged 4 commits into
masterfrom
feat/socketio-redis-adapter

Conversation

@nGervasyuk

Copy link
Copy Markdown
Collaborator

Re-opened from #351 with the branch pushed to this repo so the required SonarCloud Scan step (which needs SONAR_TOKEN, unavailable to fork PRs) can run. Code is identical.

Problem

EventsGateway uses a plain in-memory socket.io server (this.server.emit(...)). With a single instance that reaches every client, but in a multi-replica deployment each replica has its own isolated socket server:

  • A viewer's browser holds a socket to replica A.
  • A CI run creates a build / uploads screenshots → that request lands on replica Bemit only reaches clients on B.
  • The viewer on A never receives the event → live updates silently break and the UI only updates after a manual page refresh.

(In production this also shows up as repeated 400 Bad Request on /socket.io/?...&transport=polling&sid=... — the polling handshake session created on one replica isn't known to the next replica the load balancer picks.)

Change

Add an opt-in Redis adapter (@socket.io/redis-adapter):

  • When REDIS_URL is set, all instances share a Redis pub/sub backplane, so events emitted on any instance fan out to clients connected to every instance.
  • When REDIS_URL is not set, the default in-memory adapter is used — single-instance and local setups are completely unchanged.
REDIS_URL=redis://<host>:6379

New files/deps:

  • src/redis-io.adapter.tsRedisIoAdapter (extends Nest IoAdapter, attaches the Redis adapter in createIOServer).
  • src/main.ts — wires the adapter behind the REDIS_URL guard.
  • deps: @socket.io/redis-adapter, redis.

Deployment notes (infra, alongside this change)

For multi-replica correctness the Redis adapter is necessary but not sufficient — the reverse proxy also needs:

  • Sticky sessions (e.g. nginx ip_hash) so the polling handshake stays on one replica, and/or
  • WebSocket upgrade proxied for /socket.io/ (proxy_http_version 1.1 + Upgrade/Connection headers).

Testing

  • npm run build, lint and unit/e2e tests pass.
  • No behavioural change without REDIS_URL (in-memory adapter path).

Multiple API replicas each run an isolated in-memory socket.io server, so
an event emitted on one instance never reaches clients connected to the
others — live updates silently break and the UI only refreshes manually.

Add an opt-in Redis adapter: when REDIS_URL is set, all instances share a
Redis pub/sub backplane so emits fan out to every connected client. Without
REDIS_URL the default in-memory adapter is used, so single-instance and
local setups are unchanged.

@pashidlos pashidlos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requested changes

1. Docker / CI

  • Add a redis service to docker-compose.yml
  • Pass REDIS_URL=redis://redis:6379 into api
  • depends_on + healthcheck so acceptance (and the compose-backed job) actually boot the Redis adapter path

2. Env example

  • Document REDIS_URL in .env (same style as other optional vars):
# Multi-instance socket.io fan-out (optional). Unset = in-memory adapter.
# REDIS_URL=redis://localhost:6379

3. Tests

  • Unit: RedisIoAdapter — connect wires createAdapter, createIOServer attaches it; error handler / failed connect if practical with mocked redis + @socket.io/redis-adapter
  • E2E or acceptance: with REDIS_URL set (compose Redis), assert API comes up and a basic socket emit/receive still works
    • Cross-replica fan-out is optional follow-up (needs 2 API replicas)

Addresses review feedback on the socket.io Redis adapter:

- docker-compose: add a `redis` service (with healthcheck) and pass
  `REDIS_URL=redis://redis:6379` to `api` via `depends_on`, so the
  acceptance run actually boots the Redis adapter path
- .env: document the optional `REDIS_URL` variable
- unit test (RedisIoAdapter): verifies connect wires `createAdapter`,
  `createIOServer` attaches it, error handlers log instead of crashing,
  and a failed connect rejects
- acceptance test: with `REDIS_URL` set, a socket.io client connects to
  the running API

Error handlers and startup logging were already part of the original PR.
@nGervasyuk

Copy link
Copy Markdown
Collaborator Author

Addressed in aa4678b 🙌

1. Docker / CI

  • Added a redis service (redis:7-alpine) with a redis-cli ping healthcheck.
  • api now receives REDIS_URL=${REDIS_URL:-redis://redis:6379} and depends_on: redis (service_healthy), so docker compose up (and the acceptance job) boot the Redis adapter path by default.

2. Env example

  • Documented REDIS_URL in .env under the optional section:
    # Multi-instance socket.io fan-out (optional). Unset = in-memory adapter.
    #REDIS_URL=redis://localhost:6379

3. Tests

  • Unit (src/redis-io.adapter.spec.ts, mocks redis + @socket.io/redis-adapter): asserts connectToRedis wires createAdapter(pub, sub), createIOServer attaches it to the server, error handlers log instead of crashing, and a failed connect rejects.
  • Acceptance (test_acceptance/socket.spec.ts): with REDIS_URL set via compose, a socket.io-client connects to the running API — verifying the server boots and accepts socket connections with the adapter active. Cross-replica fan-out left as the optional follow-up (needs 2 API replicas).

Error handlers and startup logging were already in the original PR (redis-io.adapter.ts / main.ts).

@nGervasyuk
nGervasyuk requested a review from pashidlos July 17, 2026 10:44

@pashidlos pashidlos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Move the REDIS_URL bootstrap wiring out of main.ts (which unit tests can't
import) into a static RedisIoAdapter.use(app, url), so the enable/skip logic
is covered by unit tests. This raises coverage on new code above the quality
gate. main.ts now just calls the helper.
@sonarqubecloud

Copy link
Copy Markdown

@nGervasyuk
nGervasyuk merged commit d265ab0 into master Jul 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants