Web Socket #

WebSocket is a communication protocol built on top of TCP that begins with an HTTP upgrade handshake — the client sends a regular HTTP request, the server responds with “101 Switching Protocols”, and after that the connection is repurposed into a persistent full-duplex channel. Unlike the previous Socket article that covered low-level TCP sockets and Socket.io, this article focuses on pure WebSocket using the ws library in Node.js — the right choice when you need WebSocket without Socket.io’s overhead, for example an API gateway, microservice communication, or applications needing the standard WebSocket protocol without HTTP long-polling fallback. TypeScript makes WebSocket safer with discriminated unions for message types and type guards that validate incoming data before processing.

WebSocket vs HTTP — When to Choose WebSocket #

flowchart TD
    A{Communication Pattern?} --> B[Occasional server push\nnotifications, alerts]
    A --> C[Client request\nserver response]
    A --> D[Persistent full-duplex\ncontinuous back-and-forth]
    A --> E[One-way continuous\nserver streaming]

    B --> F[Server-Sent Events\nsimpler]
    C --> G[HTTP REST/GraphQL\nis enough]
    D --> H[WebSocket\nthe right choice]
    E --> I[SSE or HTTP streaming\nsimpler]

    style F fill:#fcc419,color:#000
    style G fill:#51cf66,color:#fff
    style H fill:#339af0,color:#fff
    style I fill:#fcc419,color:#000

Project Setup #

npm install ws
npm install --save-dev @types/ws typescript ts-node
// tsconfig.json — minimal configuration for a WebSocket project
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

Message Types — The Foundation of Type Safety #

The first step before writing the server or client is defining all possible message types. This file is imported by both:

// src/types/ws-messages.ts

// Messages from Client to Server
export type PesanMasuk =
  | { tipe: "AUTH"; token: string }
  | { tipe: "LANGGANAN"; topik: string }
  | { tipe: "BATAL_LANGGANAN"; topik: string }
  | { tipe: "KIRIM"; topik: string; isi: unknown }
  | { tipe: "PING" };

// Messages from Server to Client
export type PesanKeluar =
  | { tipe: "AUTH_OK"; sessionId: string }
  | { tipe: "AUTH_GAGAL"; alasan: string }
  | { tipe: "PESAN"; topik: string; dari: string; isi: unknown; waktu: string }
  | { tipe: "ERROR"; kode: string; pesan: string }
  | { tipe: "PONG" }
  | { tipe: "DITUTUP"; alasan: string };

// Client connection status stored by the server
export interface StatusKoneksi {
  sessionId: string;
  penggunaId: string | null;
  terautentikasi: boolean;
  topikDilanggani: Set<string>;
  terhubungPada: Date;
  lastPingPada: Date;
}

// Type guard — validate data from the network before processing
export function isPesanMasuk(data: unknown): data is PesanMasuk {
  if (typeof data !== "object" || data === null) return false;
  const d = data as Record<string, unknown>;
  if (typeof d.tipe !== "string") return false;

  switch (d.tipe) {
    case "AUTH":
      return typeof d.token === "string";
    case "LANGGANAN":
    case "BATAL_LANGGANAN":
      return typeof d.topik === "string" && d.topik.length > 0;
    case "KIRIM":
      return typeof d.topik === "string" && "isi" in d;
    case "PING":
      return true;
    default:
      return false;
  }
}

WebSocket Server with ws #

// src/server/ws-server.ts
import { WebSocketServer, WebSocket, RawData } from "ws";
import { IncomingMessage } from "http";
import { parse as parseUrl } from "url";
import type { PesanMasuk, PesanKeluar, StatusKoneksi } from "../types/ws-messages";
import { isPesanMasuk } from "../types/ws-messages";

// Extend WebSocket with additional metadata
interface WebSocketDenganMeta extends WebSocket {
  status: StatusKoneksi;
  isAlive: boolean; // For the ping-pong heartbeat
}

class WebSocketServerApp {
  private wss: WebSocketServer;
  private koneksi = new Map<string, WebSocketDenganMeta>();
  private intervalHeartbeat: NodeJS.Timer | undefined;

  constructor(private readonly port: number) {
    this.wss = new WebSocketServer({
      port,
      // Verify the client during the handshake — reject before the connection forms
      verifyClient: this.verifikasiClient.bind(this),
    });

    this.wss.on("connection", this.tanganiKoneksiBaru.bind(this));
    this.wss.on("error", (err) => console.error("[WS Server] Error:", err));

    this.mulaiHeartbeat();
    console.log(`[WS Server] Berjalan di ws://localhost:${port}`);
  }

  // Verify during the HTTP upgrade — can check Origin, rate limits, etc.
  private verifikasiClient(
    info: { origin: string; req: IncomingMessage; secure: boolean },
    callback: (result: boolean, code?: number, message?: string) => void
  ): void {
    const allowedOrigins = [
      "http://localhost:3000",
      "https://muslimapps.id",
    ];

    if (!allowedOrigins.includes(info.origin)) {
      callback(false, 403, "Origin tidak diizinkan");
      return;
    }

    callback(true);
  }

  private tanganiKoneksiBaru(ws: WebSocket, req: IncomingMessage): void {
    const wsMeta = ws as WebSocketDenganMeta;
    const sessionId = crypto.randomUUID();
    const { query } = parseUrl(req.url ?? "", true);

    wsMeta.status = {
      sessionId,
      penggunaId: null,
      terautentikasi: false,
      topikDilanggani: new Set(),
      terhubungPada: new Date(),
      lastPingPada: new Date(),
    };
    wsMeta.isAlive = true;

    this.koneksi.set(sessionId, wsMeta);
    console.log(`[WS Server] Klien terhubung: ${sessionId} (${req.socket.remoteAddress})`);

    // Handle incoming messages
    wsMeta.on("message", (data: RawData) => {
      this.prosesPesanMasuk(wsMeta, data);
    });

    // Heartbeat — respond to pong
    wsMeta.on("pong", () => {
      wsMeta.isAlive = true;
      wsMeta.status.lastPingPada = new Date();
    });

    wsMeta.on("close", (code, reason) => {
      console.log(
        `[WS Server] Klien terputus: ${sessionId} (kode: ${code}, alasan: ${reason.toString()})`
      );
      this.koneksi.delete(sessionId);
    });

    wsMeta.on("error", (err) => {
      console.error(`[WS Server] Error pada ${sessionId}:`, err.message);
      this.koneksi.delete(sessionId);
    });
  }

  private prosesPesanMasuk(ws: WebSocketDenganMeta, rawData: RawData): void {
    // Parse and validate
    let data: unknown;
    try {
      data = JSON.parse(rawData.toString());
    } catch {
      this.kirim(ws, { tipe: "ERROR", kode: "PARSE_ERROR", pesan: "Data bukan JSON yang valid" });
      return;
    }

    if (!isPesanMasuk(data)) {
      this.kirim(ws, { tipe: "ERROR", kode: "INVALID_MESSAGE", pesan: "Format pesan tidak dikenali" });
      return;
    }

    // Route by message type
    switch (data.tipe) {
      case "AUTH":
        this.tanganiAuth(ws, data.token);
        break;

      case "LANGGANAN":
        if (!ws.status.terautentikasi) {
          this.kirim(ws, { tipe: "ERROR", kode: "UNAUTH", pesan: "Autentikasi diperlukan" });
          return;
        }
        ws.status.topikDilanggani.add(data.topik);
        console.log(`[WS Server] ${ws.status.sessionId} berlangganan: ${data.topik}`);
        break;

      case "KIRIM":
        if (!ws.status.terautentikasi) {
          this.kirim(ws, { tipe: "ERROR", kode: "UNAUTH", pesan: "Autentikasi diperlukan" });
          return;
        }
        this.siarkanKeTopic(data.topik, ws.status.penggunaId!, data.isi);
        break;

      case "PING":
        this.kirim(ws, { tipe: "PONG" });
        break;

      case "BATAL_LANGGANAN":
        ws.status.topikDilanggani.delete(data.topik);
        break;
    }
  }

  private tanganiAuth(ws: WebSocketDenganMeta, token: string): void {
    // Simulated token verification
    try {
      const payload = verifikasiJWT(token);
      ws.status.penggunaId = payload.sub;
      ws.status.terautentikasi = true;
      this.kirim(ws, { tipe: "AUTH_OK", sessionId: ws.status.sessionId });
      console.log(`[WS Server] Autentikasi berhasil: ${payload.sub}`);
    } catch {
      this.kirim(ws, { tipe: "AUTH_GAGAL", alasan: "Token tidak valid atau kedaluwarsa" });
      ws.close(1008, "Autentikasi gagal"); // 1008 = Policy Violation
    }
  }

  // Broadcast to all subscribers of a given topic
  private siarkanKeTopic(topik: string, dariPenggunaId: string, isi: unknown): void {
    const pesan: PesanKeluar = {
      tipe: "PESAN",
      topik,
      dari: dariPenggunaId,
      isi,
      waktu: new Date().toISOString(),
    };

    let jumlahPenerima = 0;
    for (const koneksi of this.koneksi.values()) {
      if (
        koneksi.status.topikDilanggani.has(topik) &&
        koneksi.readyState === WebSocket.OPEN
      ) {
        this.kirim(koneksi, pesan);
        jumlahPenerima++;
      }
    }

    console.log(`[WS Server] Siaran ke topik "${topik}": ${jumlahPenerima} penerima`);
  }

  // Send a message to a single client with type safety
  private kirim(ws: WebSocket, pesan: PesanKeluar): void {
    if (ws.readyState !== WebSocket.OPEN) return;
    ws.send(JSON.stringify(pesan));
  }

  // Heartbeat: ping all clients every 30 seconds, disconnect unresponsive ones
  private mulaiHeartbeat(): void {
    this.intervalHeartbeat = setInterval(() => {
      for (const [sessionId, ws] of this.koneksi) {
        if (!ws.isAlive) {
          console.log(`[WS Server] Heartbeat gagal, memutus: ${sessionId}`);
          ws.terminate(); // Force close without a close handshake
          this.koneksi.delete(sessionId);
          continue;
        }
        ws.isAlive = false;
        ws.ping(); // Send a ping, wait for the pong
      }
    }, 30_000);
  }

  // Broadcast to ALL connected clients
  siarkanSemua(pesan: PesanKeluar): void {
    for (const ws of this.koneksi.values()) {
      if (ws.readyState === WebSocket.OPEN) {
        this.kirim(ws, pesan);
      }
    }
  }

  get statistik() {
    return {
      totalKoneksi: this.koneksi.size,
      terautentikasi: [...this.koneksi.values()].filter((ws) => ws.status.terautentikasi).length,
    };
  }

  berhenti(): void {
    clearInterval(this.intervalHeartbeat as unknown as number);
    for (const ws of this.koneksi.values()) {
      ws.close(1001, "Server ditutup"); // 1001 = Going Away
    }
    this.wss.close();
  }
}

// Mock JWT verifier
function verifikasiJWT(token: string): { sub: string } {
  if (token.startsWith("valid-")) return { sub: token.replace("valid-", "usr-") };
  throw new Error("Token tidak valid");
}

// Run the server
const server = new WebSocketServerApp(8080);

// Graceful shutdown
process.on("SIGTERM", () => server.berhenti());
process.on("SIGINT", () => server.berhenti());

WebSocket Client in the Browser #

// src/client/ws-client.ts — runs in the browser
import type { PesanKeluar, PesanMasuk } from "../types/ws-messages";

class WebSocketClient {
  private ws: WebSocket | null = null;
  private reconnectDelay = 1000;
  private readonly maxDelay = 30_000;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private harus_terhubung = true;

  constructor(
    private readonly url: string,
    private readonly token: string,
    private readonly onPesan?: (pesan: PesanKeluar) => void
  ) {}

  hubungkan(): void {
    if (this.ws?.readyState === WebSocket.OPEN) return;

    this.ws = new WebSocket(this.url);

    this.ws.addEventListener("open", () => {
      console.log("[WSClient] Terhubung");
      this.reconnectDelay = 1000; // Reset the backoff

      // Authenticate immediately after connecting
      this.kirim({ tipe: "AUTH", token: this.token });
    });

    this.ws.addEventListener("message", (event: MessageEvent<string>) => {
      try {
        const pesan = JSON.parse(event.data) as PesanKeluar;
        this.tanganiPesanMasuk(pesan);
        this.onPesan?.(pesan);
      } catch {
        console.error("[WSClient] Gagal parse pesan:", event.data);
      }
    });

    this.ws.addEventListener("close", (event) => {
      console.log(`[WSClient] Terputus (kode: ${event.code}, alasan: ${event.reason})`);

      // Don't reconnect for certain codes (auth failed, etc.)
      const tidakReconnect = [1008, 1003];
      if (this.harus_terhubung && !tidakReconnect.includes(event.code)) {
        this.jadwalkanReconnect();
      }
    });

    this.ws.addEventListener("error", () => {
      console.error("[WSClient] Error koneksi");
    });
  }

  private tanganiPesanMasuk(pesan: PesanKeluar): void {
    switch (pesan.tipe) {
      case "AUTH_OK":
        console.log(`[WSClient] Autentikasi berhasil, session: ${pesan.sessionId}`);
        break;
      case "AUTH_GAGAL":
        console.error(`[WSClient] Autentikasi gagal: ${pesan.alasan}`);
        this.harus_terhubung = false; // Don't retry if auth fails
        break;
      case "PONG":
        // The server responded to our ping
        break;
      case "ERROR":
        console.error(`[WSClient] Error dari server [${pesan.kode}]: ${pesan.pesan}`);
        break;
    }
  }

  private jadwalkanReconnect(): void {
    if (this.reconnectTimer) return;

    console.log(`[WSClient] Reconnect dalam ${this.reconnectDelay}ms...`);

    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      this.hubungkan();
    }, this.reconnectDelay);

    // Exponential backoff with jitter
    this.reconnectDelay = Math.min(
      this.reconnectDelay * 2 + Math.random() * 1000,
      this.maxDelay
    );
  }

  kirim(pesan: PesanMasuk): void {
    if (this.ws?.readyState !== WebSocket.OPEN) {
      console.warn("[WSClient] Tidak terhubung, pesan dibuang");
      return;
    }
    this.ws.send(JSON.stringify(pesan));
  }

  langganan(topik: string): void {
    this.kirim({ tipe: "LANGGANAN", topik });
  }

  batalLangganan(topik: string): void {
    this.kirim({ tipe: "BATAL_LANGGANAN", topik });
  }

  kirimPesan(topik: string, isi: unknown): void {
    this.kirim({ tipe: "KIRIM", topik, isi });
  }

  putuskan(): void {
    this.harus_terhubung = false;
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
    }
    this.ws?.close(1000, "Ditutup oleh pengguna");
  }

  get statusKoneksi(): string {
    switch (this.ws?.readyState) {
      case WebSocket.CONNECTING: return "menghubungkan";
      case WebSocket.OPEN: return "terhubung";
      case WebSocket.CLOSING: return "menutup";
      case WebSocket.CLOSED: return "terputus";
      default: return "tidak diinisialisasi";
    }
  }
}

// Usage in a web application
const klien = new WebSocketClient(
  "wss://api.muslimapps.id/ws",
  "valid-user-token",
  (pesan) => {
    if (pesan.tipe === "PESAN") {
      console.log(`[${pesan.topik}] ${pesan.dari}: ${JSON.stringify(pesan.isi)}`);
    }
  }
);

klien.hubungkan();
klien.langganan("jadwal-sholat");
klien.kirimPesan("jadwal-sholat", { kota: "Jakarta" });

Server-Sent Events — An Alternative for One-Way Push #

If you only need server → client push (no client → server), SSE is far simpler than WebSocket:

// src/server/sse-server.ts — using Express
import express from "express";

const app = express();

app.get("/notifikasi", (req, res) => {
  // Set SSE headers
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no"); // Important for Nginx

  // Send the first event
  res.write("data: {\"tipe\":\"TERHUBUNG\"}\n\n");

  // Send updates periodically
  const interval = setInterval(() => {
    const data = {
      tipe: "UPDATE",
      waktu: new Date().toISOString(),
      pesan: "Data terbaru dari server",
    };
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  }, 5000);

  // Clean up when the client disconnects
  req.on("close", () => {
    clearInterval(interval);
    console.log("SSE client terputus");
  });
});

// SSE client (browser)
// const source = new EventSource("/notifikasi");
// source.onmessage = (event) => {
//   const data = JSON.parse(event.data);
//   console.log(data);
// };

WebSocket Closing Status Codes #

CodeNameUsage
1000Normal ClosureConnection closed normally by one of the parties
1001Going AwayServer shutting down or the browser page closed
1002Protocol ErrorWebSocket protocol violation
1003Unsupported DataReceived data that can’t be processed
1006Abnormal ClosureConnection dropped without a close handshake
1007Invalid Frame PayloadInconsistent data (e.g. text that isn’t UTF-8)
1008Policy ViolationServer rejects due to policy (auth failed, etc.)
1009Message Too BigMessage exceeds the allowed size limit
1011Server ErrorServer hit an unexpected error condition
4000-4999ApplicationCustom codes applications can use

Summary #

  • Define message types as a discriminated union (PesanMasuk, PesanKeluar) and create an isPesanMasuk() type guard to validate network data — data arriving via WebSocket is typed unknown and must be validated before processing.
  • Implement a ping-pong heartbeat — WebSocket has no built-in mechanism for detecting silently dead connections; the server must send ping periodically and disconnect connections that don’t respond with pong.
  • Use verifyClient to reject invalid connections during the HTTP upgrade handshake — more efficient than rejecting after the connection forms because no connection overhead is wasted.
  • Handle different close codes — code 1008 (Policy Violation, usually failed auth) should not auto-reconnect; code 1001 (server restart) may.
  • Exponential backoff with jitter for reconnects — avoid the “thundering herd” when all clients try to reconnect at once after a server restart; jitter adds random variation to spread out requests.
  • SSE for one-way communication (server → client only) — simpler than WebSocket, runs over regular HTTP, auto-reconnects, and is supported by all modern browsers; consider SSE before choosing WebSocket if you don’t need two-way communication.
  • Use the right status codes when closing connections1000 for normal, 1001 for server shutdown, 1008 for policy violation; this helps clients decide whether to reconnect.
  • Limit message size on the serverws supports the maxPayload option (default 100MB, too large); set it to a reasonable value (e.g. 64KB) to prevent memory exhaustion from very large messages.

← Previous: Socket   Next: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact