Building a WebRTC Signaling Service with Spring Boot: Room Management, SDP Exchange, Recording Callbacks & Horizontal Scaling
This article details the architecture and implementation of a WebRTC signaling service using Spring Boot 3.2 and WebFlux, covering protocol design, room state management, media server integration, recording callbacks, horizontal scaling strategies, and fault tolerance for real-time audio/video applications.
1. Separating Signaling from Media
The article begins by clarifying the boundary between signaling (control plane) and media (data plane). Signaling responsibilities include connection lifecycle (auth, connect, heartbeat, disconnect detection), room state (presence, roles, devices), session negotiation (forwarding SDP and ICE candidates, triggering renegotiation), and business orchestration (instructing media server to create rooms, dispatch recording tasks, handle callbacks). It must not parse SDP content (mostly pass-through), touch media streams, perform transcoding/mixing, or handle ICE connectivity checks — those belong to the browser and media server.
The deployment model uses Spring Boot 3.2 + WebFlux (Reactor Netty) for stateless signaling nodes, Redis for state, Redis Pub/Sub for cross-node messaging at small scale, and Kafka for larger scale.
2. Overall Architecture
Browser/App ──WSS──► SLB/Nginx ──► Signaling Node Cluster (stateless, horizontally scalable)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Redis Cluster Redis Pub/Sub / Kafka Media Server Cluster (SFU)
(room/session state, locks) (cross-node signaling routing) │
┌──────────┴──────────┐
▼ ▼
Object Storage Recording Completion Callback
──► Signaling/Callback ServiceKey point: signaling nodes call media server HTTP/gRPC APIs directly, not via Redis/Kafka. Redis and Kafka are only for state storage and inter-node communication.
Media Server Selection
Kurento : Feature-rich, GStreamer pipelines for mixing/filters, mature Java client, but high CPU, complex cascading, declining community activity.
mediasoup : Fine-grained API (Transport/Producer/Consumer), steep learning curve but excels at large rooms with per-room Router scaling, bandwidth estimation, and simulcast. No built-in recording; requires custom ffmpeg sidecar or client-side recording.
Janus : Good plugin ecosystem, lightweight C gateway, scales via multi-process.
SRS 5 : Supports WebRTC plus RTMP/HLS distribution; recording is a first-class feature, can eliminate a separate recording pipeline.
The author chose mediasoup because classroom scenarios have widely varying room sizes requiring per-room Router scaling, and they already run a custom ffmpeg sidecar for recording. The signaling-layer concepts remain applicable regardless of media server.
3. Protocol Design
3.1 Message Envelope
{
"v": 1,
"id": "c-9f3a2b71",
"type": "offer",
"roomId": "room_10086",
"from": "u_1024",
"to": "u_2048",
"seq": 37,
"ts": 1723456789012,
"data": { }
} id: client-generated, echoed by server for request-response pairing and idempotency. seq: monotonically increasing per session for out-of-order detection — applying SDP out of order causes setRemoteDescription to fail with misleading logs. to: empty means room-level broadcast; present means peer-to-peer or directed to media server.
3.2 Room Modes
Small rooms (2–4 participants) use P2P mesh: signaling only forwards SDP and candidates, media never touches the server. Larger rooms (classrooms) switch to SFU: each client establishes a single PeerConnection with the media server; offer/answer is between client and SFU; signaling creates Transports/Producers/Consumers and broadcasts "who published what" to room members. The same envelope is used; only the to target changes.
3.3 Message Types
C→S : join — Join room, carries token (or in query)
C→S : leave — Active leave
C→S : offer / answer — SDP negotiation
C→S : candidate — ICE candidate, trickle per candidate
C→S : renegotiate — Request renegotiation (screen share, add/remove tracks)
C→S : ping — Heartbeat (or use WS Ping frame)
S→C : joined — Join success, includes room snapshot and media access params
S→C : peer-joined / peer-left — Member changes
S→C : offer / answer / candidate — Forward peer signaling
S→C : kicked — Kicked with reason code
S→C : degrade — Degradation instruction
S→C : error — Error code + readable message
3.4 Join Flow (SFU Example)
sequenceDiagram
participant C as Client
participant S as Signaling
participant M as MediaServer
participant P as Other Room Members
C->>S: join {token, roomId, devices}
S->>S: Validate JWT / room permission / capacity
S->>M: ensureRoom + createTransport(user)
M-->>S: transportId, iceServers, dtlsFingerprint
S-->>C: joined {roomSnapshot, rtcParams}
S->>P: peer-joined {userId, role, deviceIds}
C->>S: offer
S->>M: connectTransport / forward offer
M-->>S: answer
S-->>C: answer
C->>S: candidate × N
S->>M: feed each to transport
M--)C: DTLS-SRTP established (media bypasses signaling)
C->>S: produce {kind, rtpParameters}
S->>M: create Producer
S->>P: new-producer {producerId, kind}
P->>S: consume {producerId}
S->>M: create Consumer
S-->>P: consumer parametersP2P mode replaces M with the remote peer; the produce steps are omitted. Critical: joined must include a complete roomSnapshot of all existing members so clients can decide who sends the first offer. Initially "late joiner sends offer" caused glare when two users joined simultaneously. Fixed by adopting Perfect Negotiation: compare userId lexicographically, smaller becomes impolite peer and initiates offer; on collision, roll back.
3.5 ICE Candidate Batching
Candidates are numerous and fragmented. The server batches candidates per direction in 100 ms windows:
private final Map<String, Queue<IceCandidate>> buffer = new ConcurrentHashMap<>();
void onCandidate(SessionCtx ctx, IceCandidate c) {
String key = ctx.roomId() + ":" + ctx.userId() + "->" + c.target();
buffer.computeIfAbsent(key, k -> new ConcurrentLinkedQueue<>()).add(c);
}
@Scheduled(fixedDelay = 100)
void flush() {
buffer.forEach((key, q) -> {
List<IceCandidate> batch = new ArrayList<>();
IceCandidate c;
while ((c = q.poll()) != null) batch.add(c);
if (batch.isEmpty()) return;
router.sendBatch(key, "candidate-batch", batch);
});
} ConcurrentLinkedQueueis used instead of CopyOnWriteArrayList because the latter copies the array on every add, which is costly under high-frequency writes. The @Scheduled task runs on a single thread by default, so the flush logic must be lightweight and avoid I/O.
Timing issue: candidates often arrive before the remote side has set its remote description. The server caches them (TTL 30 s) and replays after peer-joined or answer arrives.
3.6 Renegotiation & Abnormal Disconnect
Renegotiation (screen share, camera toggle) follows standard full SDP exchange. To prevent concurrent renegotiations, the server acquires a short-lived lock on (roomId, from, to) using SET key val NX EX 10 with Lua script for safe release (or Redisson). A past incident: renegotiation A timed out, lock auto-released, B acquired it, A recovered and deleted B's lock, causing dual renegotiation. If lock acquisition fails, return error: RENEGOTIATE_BUSY; client backs off and retries — no server-side queuing.
Abnormal disconnect signals: WebSocket close/error, and application-layer heartbeat timeout. Two cases:
Clean exit, process kill, app crash: OS sends FIN/RST, server gets immediate close — similar to active leave, only difference is whether to broadcast "disconnected" vs "left".
Network loss with TCP connection still alive (tunnel, Wi-Fi→4G, elevator): no events arrive; must rely on heartbeat timeout or TCP keepalive (default 2 hours, too slow).
Mobile scenarios use a 30-second grace period: on close or heartbeat timeout, mark suspending, broadcast peer-suspending (not peer-left), allow reconnection within 30 s to resume state; after timeout, truly remove and release resources. This avoids the cost of full teardown/rebuild for transient network changes.
4. Room & Session State
4.1 Redis Key Design
rtc:room:{roomId}:meta(Hash) — ownerId, mediaRoomId, mode, maxMembers, createdAt — TTL: None (explicit delete) rtc:room:{roomId}:members (Hash) — field=connId → {userId, nodeId, role, devices, joinAt, state} — TTL: None rtc:room:{roomId}:hb (ZSet) — member=connId, score=last heartbeat timestamp — TTL: None rtc:room:{roomId}:ban (Set) — Kicked/banned userIds — TTL: 24h rtc:conn:{connId} (String) — {userId, roomId, nodeId} — TTL: 60s (heartbeat renewal) rtc:user:{userId}:rooms (Set) — User's current rooms — TTL: 24h meta has no TTL intentionally — room metadata cleanup is driven by business-side room destruction logic; otherwise Redis accumulates zombie keys after a year. hb ZSet is maintained via ZREM; member departure must synchronously remove the entry, not rely solely on TTL (ZSet expiry would wipe the entire room's heartbeats). Using connId as the field in members naturally supports multi-device login without extra structure.
4.2 Heartbeat & Timeout
Do not use Redis keyspace notifications for expiry eviction — delivery is not guaranteed and latency is high. Each node scans its local connections every 5 seconds:
@Scheduled(fixedDelay = 5_000)
public void scan() {
long deadline = System.currentTimeMillis() - 45_000; // 15s heartbeat × 3 cycles
for (SessionCtx ctx : localSessions.values()) {
if (ctx.lastPongAt() < deadline) {
log.warn("heartbeat timeout, connId={}, user={}", ctx.connId(), ctx.userId());
roomService.suspend(ctx.roomId(), ctx.connId(), SuspendReason.HEARTBEAT_TIMEOUT);
ctx.session().close().subscribe();
}
}
}Each node scans only its local connections — natural sharding, no cross-node contention. rtc:conn:{connId} has a 60s TTL as a safety net against node crashes leaving stale data. Pitfall: when tens of thousands of connections expire simultaneously, the suspend Redis operations can queue and block the Netty event loop. Solution: offload to a dedicated bounded thread pool with batched pipeline execution.
4.3 Multi-Device & Kick
Multi-device policy per business: IM uses SINGLE (new connection kicks old); classroom uses MULTI (PC + phone); MULTI_LIMIT(n) caps at n devices.
Kick implementation notes:
Avoid full HGETALL on large rooms; maintain a secondary userId → connId index.
Cross-node delivery: check if target node is local before publishing to Pub/Sub.
public void kick(String roomId, String targetUserId, String reason) {
String membersKey = "rtc:room:" + roomId + ":members";
Map<Object, Object> members = redis.opsForHash().entries(membersKey);
members.forEach((connId, json) -> {
Member m = parse(json);
if (!m.userId().equals(targetUserId)) return;
redis.opsForHash().delete(membersKey, connId);
redis.opsForSet().add("rtc:room:" + roomId + ":ban", targetUserId);
router.sendToNode(m.nodeId(), "kicked", Map.of(
"userId", targetUserId, "reason", reason));
});
}Deleting the connection and adding to the ban set is insufficient because JWTs are valid for 2 hours; a kicked user could rejoin with the old token. Therefore, on kick, also add the current JWT's jti to a blacklist, and during join validation check both the ban set and "room ended" flag. Both layers are enforced: ban blocks at room level, jti blacklist blocks at token level. State consistency: all room state writes go through a single RoomService entry point, guarded by a Redisson lock on rtc:room:{roomId}:lock to make "check capacity + write member" atomic. Lease time 10 s is sufficient for the few Redis operations; too short risks lock expiry mid-operation, too long blocks subsequent requests on failure.
5. Media Server Integration
5.1 Lazy Room Creation & Cleanup
Business rooms may live for weeks; media rooms exist only while participants are present. Media room creation is lazy with double-checked locking:
public MediaRoom ensureMediaRoom(String roomId) {
String metaKey = "rtc:room:" + roomId + ":meta";
String existing = (String) redis.opsForHash().get(metaKey, "mediaRoomId");
if (existing != null) return MediaRoom.of(existing);
RLock lock = redisson.getLock("rtc:lock:media:" + roomId);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
existing = (String) redis.opsForHash().get(metaKey, "mediaRoomId");
if (existing != null) return MediaRoom.of(existing); // double-check
MediaRoom mr = mediaClient.createRoom(roomId,
MediaOptions.builder().codecs(List.of("opus", "vp8", "h264")).build());
redis.opsForHash().put(metaKey, "mediaRoomId", mr.id());
return mr;
}
throw new BizException("MEDIA_ROOM_BUSY");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BizException("MEDIA_ROOM_INTERRUPTED");
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}Cleanup trigger: "last member leaves + 5-minute grace period". Grace period is essential — a user hitting F5 causes momentary emptiness; without it, media rooms would thrash. Use Redisson DelayedQueue or Kafka delayed messages; at execution time, reconfirm room is empty.
5.2 Publish/Subscribe Auth
Media access params issued to clients must be short-lived and per-user:
public RtcParams issueRtcParams(String roomId, String userId, String role) {
MediaRoom mr = ensureMediaRoom(roomId);
String nonce = UUID.randomUUID().toString();
redis.opsForValue().set(
"rtc:rtcparams:" + nonce, userId + ":" + roomId, Duration.ofSeconds(60));
try {
return RtcParams.builder()
.roomId(mr.id())
.transportId(mediaClient.createTransport(mr.id(), userId))
.iceServers(iceConfigService.current())
.authNonce(nonce)
.expireAt(System.currentTimeMillis() + 60_000)
.build();
} catch (RuntimeException e) {
redis.delete("rtc:rtcparams:" + nonce); // cleanup on failure
throw e;
}
}Media server auth callback verifies the nonce; DEL returns 1 only on first use — one-time, replay-resistant, superior to long-lived tokens in URLs.
5.3 Recording Dispatch
Signaling does not touch media streams; it only dispatches task descriptions:
public RecordTask startRecord(String roomId, RecordSpec spec) {
RecordTask task = recordRepo.create(roomId, spec); // persist, status CREATED
mediaClient.startRecord(StartRecordReq.builder()
.taskId(task.id())
.mediaRoomId(ensureMediaRoom(roomId).id())
.layout(spec.layout()) // picture-in-picture / side-by-side / mixed
.output(Output.builder()
.format("mp4")
.callbackUrl(cfg.getCallbackBase() + "/api/rtc/record/callback")
.build())
.build());
recordRepo.updateStatus(task.id(), RecordStatus.STARTING);
return task;
}5.4 Recording Callback
Callback endpoint must do three things: verify signature, ensure idempotency, validate state machine transitions. Signature verification: @RequestBody String rawBody captures the raw payload; re-serializing a deserialized object breaks HMAC due to field ordering, escaping, whitespace. Verify against raw body.
@PostMapping("/api/rtc/record/callback")
public ResponseEntity<Map<String, Object>> onRecordEvent(
@RequestBody String rawBody,
@RequestHeader(value = "X-Record-Sign", required = false) String sign) {
if (!signVerifier.verify(rawBody, sign)) {
return ResponseEntity.status(401).body(Map.of("code", 401));
}
RecordEvent event = jsonReader.read(rawBody, RecordEvent.class);
switch (event.event()) {
case "started" -> recordRepo.transit(event.taskId(), STARTING, RECORDING);
case "progress" -> recordRepo.updateDuration(event.taskId(), event.durationMs());
case "completed" -> {
// Idempotency via state machine CAS: only process file if transition succeeds
if (recordRepo.transit(event.taskId(), RECORDING, COMPLETED)) {
ossService.register(event.taskId(), event.files()); // verify existence + MD5
}
}
case "failed" -> recordRepo.transit(event.taskId(), RECORDING, FAILED);
default -> log.warn("unknown record event: {}, task={}", event.event(), event.taskId());
}
return ResponseEntity.ok(Map.of("code", 0));
}Idempotency evolution: initially used Redis SETNX with key taskId:eventType:seq , but seq reset on media server restart caused new callbacks to be incorrectly dropped. Switched to pure DB state machine CAS: UPDATE ... SET status='COMPLETED' WHERE id=? AND status='RECORDING' ; zero affected rows means already processed. Redis layer remains as a traffic filter but not the source of truth. Thread model: signaling runs on WebFlux (Netty event loop), but this callback uses blocking Redis and DB calls. Must either subscribeOn(Schedulers.boundedElastic()) or move such endpoints to a separate Spring MVC process — they are a different load profile from WebSocket long connections. Hard-learned pitfalls: Media server retries callbacks; without CAS, a single task gets recorded twice. completed may arrive while files are still flushing to disk; ossService.register would 404. Solution: "callback primary + scheduled scan compensation" — both paths can drive task to terminal state. Long recordings must be segmented (30 min per segment); otherwise a single file failure loses the entire session. Notary scenarios also require per-segment timestamps and hashes for audit.
6. Horizontal Scaling
6.1 Sticky Sessions
WebSocket is stateful; all frames of a connection must hit the same node. Layer-4 LB only sees IP/port, cannot hash on business parameters. ip_hash is simple but concentrates users behind a corporate NAT onto one node. Options: Layer-7 consistent hash on query parameter uid (Nginx: hash $arg_uid consistent or cloud SLB custom session key). No stickiness: keep all state in Redis, any node can handle any connection. Chosen: Layer-7 consistent hash on uid . Cost: extra proxy layer, tuning proxy_read_timeout etc. Benefit: on scale-out only ~1/N connections reconnect. Client re-joins, state restored from Redis, user perceives no interruption.
6.2 Cross-Node Broadcast
Small scale: Redis Pub/Sub. Each node subscribes to rtc:node:{nodeId} ; sender looks up target's nodeId from member table and publishes precisely.
public class SignalRouter {
private final StringRedisTemplate redis;
private final String myNodeId;
private final LocalSessionRegistry local; // connId -> WebSocketSession
public void route(Signal msg) {
if (msg.to() != null) {
Member target = roomService.findMember(msg.roomId(), msg.to());
if (target == null) return;
if (myNodeId.equals(target.nodeId())) {
local.send(target.connId(), msg);
} else {
redis.convertAndSend("rtc:node:" + target.nodeId(), serialize(msg));
}
} else {
// Room broadcast: deduplicate by node
Set<String> nodes = roomService.members(msg.roomId()).values().stream()
.map(Member::nodeId).collect(Collectors.toSet());
for (String node : nodes) {
if (myNodeId.equals(node)) local.broadcast(msg.roomId(), msg);
else redis.convertAndSend("rtc:node:" + node, serialize(msg));
}
}
}
}RedisMessageListenerContainer uses its own threads, isolated from WebFlux event loop, but subscription callbacks must stay lightweight. Pub/Sub does not guarantee delivery; broadcasts during node restart are lost. Acceptable for signaling — clients reconnect and fetch snapshots to rebuild. For must-deliver commands like kick, the ban set provides the ultimate guarantee. Larger scale: switch to Kafka. Topic partitioned by roomId to preserve per-room ordering. Each node runs a consumer in its own group (group id includes nodeId), consumes all partitions, but processes only messages where nodeId == self — effectively using Kafka as a broadcast bus, trading partition count for parallelism. Total cluster traffic multiplies by node count; sustainable up to ~100k connections. Beyond that, assign room ownership to specific nodes (see 6.3) so intra-room signaling stays local.
6.3 Room Ownership
When room count pushes single Redis QPS beyond ~30k (Hash ops), introduce room affinity:
public String roomOwnerNode(String roomId) {
return nodes.get(Math.floorMod(murmur3(roomId), nodes.size()));
}Route room members to the owner node via SLB consistent hash on roomId ; intra-room signaling becomes zero cross-node hops. Scaling uses virtual nodes with double-capacity expansion to minimize data movement. Critical: node list must come from a centralized config/registry. Early mistake: each JVM computed hash independently; during scaling window, two hash rings coexisted, splitting a room across two nodes — users couldn't see each other. Fixed by publishing a single versioned node list.
7. Weak Network & Fault Tolerance
Signaling layer can do four things to improve user experience under adverse conditions.
7.1 ICE Failure & Recovery
Client listens to iceconnectionstatechange :
pc.addEventListener('iceconnectionstatechange', () => {
const s = pc.iceConnectionState;
if (s === 'failed') return recover();
if (s === 'disconnected') {
// disconnected is often transient; give 5s to self-recover
setTimeout(() => { if (pc.iceConnectionState === 'disconnected') recover(); }, 5000);
}
});
async function recover() {
if (pc.restartIce) {
pc.restartIce(); // sets iceRestart flag on next offer
}
const offer = await pc.createOffer({ iceRestart: !pc.restartIce });
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'renegotiate', roomId, data: { sdp: offer.sdp } }));
}Do not use both restartIce() and createOffer({iceRestart:true}) together — the former already sets the flag; doubling is harmless but confusing. Safari added restartIce late; fallback is the createOffer parameter. Server on renegotiate : forward to negotiation peer (remote peer in P2P, media server in SFU), clear old candidate cache, optionally re-issue iceServers (TURN addresses rotate). Reconnection backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s. After three consecutive recovery failures, show "call interrupted" UI with a manual redial button — endless spinner is the worst UX.
7.2 Network Switching
Wi-Fi↔4G handover inevitably fails ICE. Beyond state monitoring, watch navigator.connection change events and online / offline — but Network Information API is absent in Firefox; feature-detect before use. Server side: on new connection join, mark old connection as superseded , allow 15s parallel window until new media path is confirmed, then close old. This 15s differs from the 30s suspend grace period: one handles handover with a fresh connection; the other handles total loss waiting for reconnect.
7.3 Signaling Timeout & Degradation
Server sets timers per SDP exchange: offer sent, no answer within 8s; or candidates exchanged but media not connected after 15s → trigger degradation.
void scheduleDegrade(SessionCtx ctx) {
scheduler.schedule(() -> {
if (!ctx.isMediaConnected()) {
router.send(ctx, "degrade", Map.of(
"reason", "ICE_TIMEOUT",
"action", "AUDIO_ONLY",
"detail", "video m-line set to inactive"
));
// Client disables video track; re-negotiates when conditions improve
}
}, 15, TimeUnit.SECONDS);
}Degradation order: reduce resolution → reduce framerate → disable video keep audio → fall back to text-only chat. In customer service, "audio works" is far better than "frozen video but connected".
7.4 Other Safeguards
WebSocket frame size limit: Reactor Netty defaults maxFramePayloadLength to 64KB; exceeding drops connection. Application layer also validates length to prevent malicious large payloads.
Rate limiting per connection: sliding window, 30 msg/s cap; excess returns RATE_LIMIT and throttles.
Media server node unavailable → return MEDIA_UNAVAILABLE; client shows "retry later" instead of infinite reconnect storm that could drag down signaling nodes.
8. Security
8.1 Join Token
"Knowing roomId grants entry" is unacceptable. Use JWT signed by business backend, validated once at join:
public String issueJoinToken(String userId, String roomId, Role role, RtcPerm perm) {
return Jwts.builder()
.id(UUID.randomUUID().toString()) // jti for blacklist
.subject(userId)
.claim("roomId", roomId)
.claim("role", role.name())
.claim("pub", perm.canPublish())
.claim("canSub", perm.canSubscribe())
.claim("maxBitrate", perm.maxBitrate())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 2 * 3600_000L))
.signWith(Keys.hmacShaKeyFor(jwtKey), Jwts.SIG.HS256)
.compact();
}Past pitfall: sub is a registered JWT claim (subject). Early code used sub for "can subscribe" boolean, clashing with subject — parsing sometimes yielded boolean, sometimes userId. Renamed to canSub . Also, jjwt 0.12+ deprecated signWith(SignatureAlgorithm.HS256, key) ; must use Keys.hmacShaKeyFor + Jwts.SIG.HS256 . Validation checks: jti not in blacklist, userId not in ban set, room not full, now < exp . JWT used only for join handshake; subsequent messages rely on connection-bound session context — re-verifying JWT per message is too expensive.
8.2 Permissions
Host: full control.
Teacher/Agent: publish/subscribe, screen share; recording and kick require host approval.
Student/Customer: basic pub/sub.
Observer/QA: subscribe only.
Permissions baked into JWT at join. Changes: role change → issue new token, force reconnect (simple but causes flash); temporary mute/unmute → server sends perm-change message (smooth but requires per-message re-authorization). Production uses both: role changes via token, transient mute via perm-change .
8.3 Anti-Leeching & Recording Theft
Publish/subscribe URLs carry sign (HMAC(streamName + expire)) and expire ≤ 5 min; media server callback verifies.
One-time nonce, consumed on first use.
Full chain WSS + DTLS-SRTP.
Media server API ports only exposed to internal network; only signaling service can reach them.
Dynamic watermark: client-side canvas overlay is trivial to bypass; real protection requires server-side mixing (burned into video), cost calculated separately. Audit logs mandatory: join, leave, recording, kick — retained ≥180 days. Disabling right-click, detecting screen-capture software — only stops casual users; don't rely on client-side measures.
9. Production Numbers
Reference environment: 3 signaling nodes (8C16G), Spring Boot 3.2 + Reactor Netty, Redis 7 master-replica, Kafka 3 partitions, mediasoup 2 nodes. Numbers correlate with SDK version and message size; do not use directly for capacity planning.
Single node WebSocket connections: 50k (fd limit raised to 100k), heap+off-heap ~3.2 GB.
Heartbeat every 15s → ~3,300 QPS per node, matches calculation.
Join path (Redis + media server createTransport): ~1,200 TPS per node, P99 68 ms — bottleneck is media server, not Redis.
Same-node signaling forward P99 6 ms; cross-node via Pub/Sub + serialization P99 28 ms.
3 nodes, 150k connections, 10k rooms avg 10 users → CPU ~55% average.
Media side: 8C16G handles ~1,200 VP8 720p subscriptions; highly dependent on bitrate, SVC, camera-on ratio.
Optimizations by impact: SDP typically 3–6 KB, dominates message size. Enabling WebSocket permessage-deflate cuts signaling bandwidth ~60%. Only compress text frames; binary frames already compressed. Reuse WS Ping/Pong frames for heartbeat; avoid application-layer ping message, saves parsing. Client sends Ping every 15s; server relies on protocol stack for Pong. Cross-node serialization: replace JSON with MessagePack or Protobuf — 20-30% size reduction, mainly eliminates repeated keys and numeric parsing overhead. SDP is a string, barely compressible. Reactor Netty pools ByteBuf in direct memory; set -XX:MaxDirectMemorySize to 1.2–1.5× heap. Omitting this causes OOM with "Direct buffer memory" — heap dump shows nothing, making diagnosis painful.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
