WebSocket Complete Guide | Real-Time, Socket.io, Chat, Notifications, Games

WebSocket Complete Guide | Real-Time, Socket.io, Chat, Notifications, Games

이 글의 핵심

Hands-on guide to WebSocket and Socket.io: native API, rooms, Redis adapter, and the protocol layer—HTTP upgrade, frames, text vs binary frames, Ping/Pong heartbeats, and production patterns.

At a glance

This guide covers the browser WebSocket API, Socket.io (rooms, broadcast), a chat example, horizontal scaling with Redis, and RFC 6455 internals so you can debug proxies, handshakes, and heartbeats in production.

Field note: Migrating a polling-based chat to WebSocket once cut server load by about 90% and reduced delivery delay from seconds to near real time.

When you need real time

Scenario 1: Polling wastes resources — Calling an API every second loads the server; WebSocket keeps one connection open.

Scenario 2: Stale data — Polling adds latency up to the poll interval; WebSocket pushes immediately.

Scenario 3: Collaboration — Features like live cursors or co-editing map naturally to a persistent bidirectional channel.


1. What is WebSocket?

Core properties

WebSocket is a standardized protocol for full-duplex communication over a long-lived connection.

Benefits:

  • Bidirectional — server and client can both send at any time
  • Low latency — typically sub-10 ms on a good network
  • Efficient — no repeated HTTP headers per message once upgraded
  • Built into browsersWebSocket API

HTTP vs WebSocket:

  • HTTP — request/response, often short-lived connections
  • WebSocket — persistent session after the upgrade

2. Protocol internals: upgrade, frames, text/binary, heartbeats (RFC 6455)

Libraries (ws, Socket.io, browser WebSocket) hide framing and masking. For production incidents—proxies closing idle connections, 400 on handshake, or invalid UTF-8 closes—you need the RFC 6455 skeleton.

2.1 From HTTP to WebSocket: the upgrade handshake

WebSocket is not a separate TCP port protocol by default. It reuses the TCP connection and switches protocols with one HTTP/1.1 request:

  1. The client sends GET with Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Version: 13, and Sec-WebSocket-Key (16 random bytes, Base64).
  2. The server concatenates the key with the magic string 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, computes SHA-1, Base64-encodes the result, and returns it as Sec-WebSocket-Accept with status 101 Switching Protocols.
  3. After 101, the same socket carries WebSocket frames, not HTTP message bodies.

Reverse proxies (Nginx, Cloudflare) must forward Upgrade and Connection end-to-end; otherwise you never get 101 or the connection drops immediately. For restrictive networks, wss:// on port 443 is usually the most reliable option.

2.2 Frame structure (summary)

A message may be split across one or more frames. The frame header includes:

  • FIN — whether this frame ends the message (fragmentation possible when FIN is 0).
  • Opcode — e.g. 1 text, 2 binary, 8 close, 9 ping, 10 pong.
  • Payload length — 7-bit length, or extended 16/64-bit fields for larger payloads.
  • Masking — frames from client to server must be XOR-masked per RFC (mitigates cache poisoning). Server to client is not masked.

Application code usually sees reassembled messages, but huge payloads may be fragmented—always set a maximum message size in your stack (read_message_max, server limits, etc.).

2.3 Text vs binary frames

Text (opcode 1)Binary (opcode 2)
PayloadUTF-8 textAny bytes (Protobuf, chunks, etc.)
BrowserOften arrives as a string in onmessageBlob / ArrayBuffer
PitfallsInvalid UTF-8 may trigger a protocol error and closeEncoding is your responsibility

JSON chat and notifications usually use text. Protobuf, MsgPack, or file chunks belong in binary. You can mix both on one connection if your app distinguishes frame types.

2.4 Heartbeats: Ping/Pong vs app-level keepalive

  • Wire-levelPing (9) and Pong (10) control frames generate traffic so NATs and load balancers do not treat the TCP session as idle. Pong typically echoes Ping payload.
  • Application-level — JSON {"type":"ping"} or similar is common. Socket.io has its own heartbeat protocol on top of WebSocket.

If the proxy read_timeout is shorter than your heartbeat interval, the link dies regardless of app health. Align Ping period (e.g. 20–30s) with proxy and ALB idle timeouts.

2.5 Production patterns

  • WSS on 443 — best pass-through corporate firewalls and proxies.
  • Sticky sessions or shared state — pin a user to an instance when needed; otherwise use Redis Pub/Sub (or streams) to broadcast across nodes.
  • Reconnect — exponential backoff, offline queues, last event / cursor for catch-up after reconnect.
  • Backpressure — do not unboundedly send to slow clients; cap queues and define drop policies.
  • Observability — metrics for open connections, messages/sec, handshake failures, abnormal closes (e.g. 1006 without a close frame).

3. Native WebSocket API

Server (Node.js)

// server.ts
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
  console.log('Client connected');
  ws.on('message', (data) => {
    console.log('Received:', data.toString());

    // Echo
    ws.send(`Echo: ${data}`);
  });
  ws.on('close', () => {
    console.log('Client disconnected');
  });
  ws.send('Welcome!');
});
console.log('WebSocket server running on ws://localhost:8080');

Client (browser)

// client.ts
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
  console.log('Connected');
  ws.send('Hello Server!');
};
ws.onmessage = (event) => {
  console.log('Received:', event.data);
};
ws.onerror = (error) => {
  console.error('Error:', error);
};
ws.onclose = () => {
  console.log('Disconnected');
};

4. Socket.io

Install

# Server
npm install socket.io
# Client
npm install socket.io-client

Server

// server.ts
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
});
io.on('connection', (socket) => {
  console.log('User connected:', socket.id);
  socket.on('message', (data) => {
    console.log('Message:', data);

    // All clients
    io.emit('message', data);

    // Everyone except sender
    socket.broadcast.emit('message', data);

    // Specific socket — set targetSocketId in your app
    // socket.to(targetSocketId).emit('message', data);
  });
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});
httpServer.listen(3000, () => {
  console.log('Server running on :3000');
});

Client

// client.ts
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000');
socket.on('connect', () => {
  console.log('Connected:', socket.id);
});
socket.on('message', (data) => {
  console.log('Received:', data);
});
socket.emit('message', { text: 'Hello!' });

5. Example: chat app

Server

// server.ts
import { Server } from 'socket.io';
const io = new Server(3000, {
  cors: { origin: '*' },
});
interface Message {
  user: string;
  text: string;
  timestamp: string;
}
io.on('connection', (socket) => {
  console.log('User connected:', socket.id);
  socket.on('join', (room: string) => {
    socket.join(room);
    socket.to(room).emit('user-joined', socket.id);
    console.log(`${socket.id} joined ${room}`);
  });
  socket.on('message', (data: { room: string; message: Message }) => {
    io.to(data.room).emit('message', data.message);
  });
  socket.on('typing', (room: string) => {
    socket.to(room).emit('typing', socket.id);
  });
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});

Client (React)

// Chat.tsx
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
let socket: Socket;
export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const room = 'general';
  useEffect(() => {
    socket = io('http://localhost:3000');
    socket.emit('join', room);
    socket.on('message', (message: Message) => {
      setMessages((prev) => [...prev, message]);
    });
    socket.on('typing', (userId: string) => {
      console.log(`${userId} is typing...`);
    });
    return () => {
      socket.disconnect();
    };
  }, []);
  const sendMessage = () => {
    if (input.trim()) {
      const message: Message = {
        user: 'Me',
        text: input,
        timestamp: new Date().toISOString(),
      };
      socket.emit('message', { room, message });
      setInput('');
    }
  };
  const handleTyping = () => {
    socket.emit('typing', room);
  };
  return (
    <div>
      <div>
        {messages.map((msg, i) => (
          <div key={i}>
            <strong>{msg.user}:</strong> {msg.text}
          </div>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => {
          setInput(e.target.value);
          handleTyping();
        }}
        onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
      />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
}

6. Redis adapter (scale-out)

import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const io = new Server(3000);
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
  io.adapter(createAdapter(pubClient, subClient));
  console.log('Redis adapter connected');
});
// Multiple Node processes can now share Socket.io events

Summary and checklist

Takeaways

  • RFC 6455101 upgrade, opcodes, masking, Ping/Pong explain most proxy and handshake failures
  • WebSocket — bidirectional real-time channel
  • Socket.io — rooms, namespaces, reconnection helpers
  • Redis adapter — horizontal scale for Socket.io

Checklist

  • WebSocket or Socket.io server
  • Rooms / channels if needed
  • Authentication (token during handshake or first message)
  • Error handling and logging
  • Redis (or equivalent) for multi-instance broadcast
  • Deploy with correct proxy timeouts and WSS

FAQ

WebSocket vs Server-Sent Events?

WebSocket is bidirectional. SSE is server → client only. For chat and collaboration, WebSocket (or Socket.io) is the usual choice. See also WebSocket vs SSE vs long polling.

What happens when the connection drops?

Socket.io retries with its own rules. For native WebSocket, implement reconnect with backoff and state resync (last message id, snapshot, etc.).

Is this production-ready?

Yes—many products use WebSocket or Socket.io behind TLS and Redis. Tune heartbeats, limits, and observability as you would for any long-lived TCP service.


Keywords

WebSocket, Real-time, Socket.io, RFC 6455, Node.js, Backend