Web Socket

Web Socket #

HTTP dirancang untuk pola request-response: klien bertanya, server menjawab, koneksi ditutup. Pola ini bagus untuk halaman web statis, tapi tidak untuk aplikasi yang butuh data mengalir secara berkelanjutan — papan skor yang berubah setiap detik, notifikasi real-time, editor kolaboratif, atau antarmuka trading. Setiap kali klien ingin tahu apakah ada update, ia harus mengirim request baru. WebSocket memecahkan ini: setelah handshake awal lewat HTTP, koneksi di-upgrade menjadi saluran dua arah penuh yang tetap terbuka. Server bisa mengirim data ke klien kapan saja tanpa menunggu permintaan — dan sebaliknya. Artikel ini membahas cara kerja protokol WebSocket, implementasinya dengan tiga pendekatan di Java (Jakarta EE, Spring Boot, Jetty), broadcast ke semua klien, heartbeat untuk menjaga koneksi tetap hidup, dan kapan WebSocket adalah pilihan yang tepat.

Cara Kerja WebSocket #

WebSocket bukan protokol tersendiri yang berdiri sendiri — ia dimulai dari HTTP lalu di-upgrade. Proses ini disebut WebSocket handshake.

Handshake HTTP ke WebSocket #

Klien → Server (HTTP Upgrade Request):
  GET /ws HTTP/1.1
  Host: example.com
  Upgrade: websocket
  Connection: Upgrade
  Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
  Sec-WebSocket-Version: 13

Server → Klien (HTTP 101 Switching Protocols):
  HTTP/1.1 101 Switching Protocols
  Upgrade: websocket
  Connection: Upgrade
  Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Setelah handshake berhasil, koneksi TCP yang sama tetap terbuka dan kedua sisi bisa mengirim frames kapan saja.

Tipe Frame WebSocket #

FrameKodeKegunaan
Text0x1Pesan teks (UTF-8)
Binary0x2Data biner
Close0x8Menutup koneksi dengan kode dan alasan
Ping0x9Cek koneksi masih hidup
Pong0xABalas ping
sequenceDiagram
    participant B as Browser / Klien
    participant S as Server

    B->>S: HTTP GET /ws (Upgrade: websocket)
    S->>B: HTTP 101 Switching Protocols
    Note over B,S: Koneksi WebSocket terbuka (full-duplex)
    B->>S: Frame teks: "Halo server"
    S->>B: Frame teks: "Halo klien!"
    S->>B: Frame teks: "Update data: 42" (server push, tanpa diminta)
    B->>S: Frame ping
    S->>B: Frame pong
    B->>S: Frame close
    S->>B: Frame close
    Note over B,S: Koneksi ditutup

WebSocket vs HTTP Polling #

AspekHTTP PollingWebSocket
KoneksiBaru setiap requestSatu koneksi persisten
LatensiTinggi (overhead HTTP header)Rendah (langsung kirim frame)
Server push✗ Tidak bisa✓ Bisa kapan saja
Beban serverTinggi (banyak request)Rendah
Kasus terbaikData jarang berubahData sering berubah / real-time

Jakarta EE WebSocket API #

Jakarta EE (sebelumnya Java EE) menyediakan API WebSocket standar melalui paket jakarta.websocket. Pendekatan ini menggunakan anotasi untuk mendefinisikan endpoint dan event handler. Cocok dipakai bersama server aplikasi seperti Tomcat, WildFly, atau Payara.

Dependensi #

<!-- Maven — untuk development (runtime disediakan server) -->
<dependency>
    <groupId>jakarta.websocket</groupId>
    <artifactId>jakarta.websocket-api</artifactId>
    <version>2.1.0</version>
    <scope>provided</scope>
</dependency>

Endpoint Server dengan Anotasi #

import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;

@ServerEndpoint("/ws/chat")
public class ChatEndpoint {

    // Set semua sesi aktif — CopyOnWriteArraySet aman untuk multithreading
    private static final Set<Session> sesiAktif = new CopyOnWriteArraySet<>();

    @OnOpen
    public void onOpen(Session sesi) {
        sesiAktif.add(sesi);
        System.out.println("Terhubung: " + sesi.getId());
        broadcast("Pengguna baru bergabung. Total: " + sesiAktif.size(), null);
    }

    @OnMessage
    public void onMessage(String pesan, Session pengirim) {
        System.out.println("[" + pengirim.getId() + "] " + pesan);
        // Siarkan pesan ke semua klien kecuali pengirim
        broadcast(pesan, pengirim);
    }

    @OnClose
    public void onClose(Session sesi, CloseReason alasan) {
        sesiAktif.remove(sesi);
        System.out.println("Terputus: " + sesi.getId() + " — " + alasan.getReasonPhrase());
        broadcast("Pengguna keluar. Total: " + sesiAktif.size(), null);
    }

    @OnError
    public void onError(Session sesi, Throwable error) {
        System.err.println("Error pada sesi " + sesi.getId() + ": " + error.getMessage());
        sesiAktif.remove(sesi);
    }

    private void broadcast(String pesan, Session kecuali) {
        for (Session s : sesiAktif) {
            if (s.equals(kecuali)) continue;
            if (s.isOpen()) {
                try {
                    s.getBasicRemote().sendText(pesan);
                } catch (IOException e) {
                    System.err.println("Gagal kirim ke " + s.getId() + ": " + e.getMessage());
                }
            }
        }
    }
}

Mengirim Pesan ke Klien Tertentu #

@OnMessage
public void onMessage(String pesan, Session pengirim) {
    // Kirim ke pengirim saja (synchronous)
    try {
        pengirim.getBasicRemote().sendText("Echo: " + pesan);
    } catch (IOException e) {
        e.printStackTrace();
    }

    // Kirim asynchronous — lebih efisien untuk server dengan banyak klien
    pengirim.getAsyncRemote().sendText("Async: " + pesan);

    // Kirim data biner
    byte[] data = pesan.getBytes();
    try {
        pengirim.getBasicRemote().sendBinary(java.nio.ByteBuffer.wrap(data));
    } catch (IOException e) {
        e.printStackTrace();
    }
}

Endpoint dengan Parameter Path #

import jakarta.websocket.server.PathParam;

// URL: /ws/room/general atau /ws/room/coding
@ServerEndpoint("/ws/room/{namaRuangan}")
public class RuanganEndpoint {

    @OnOpen
    public void onOpen(Session sesi, @PathParam("namaRuangan") String ruangan) {
        System.out.println("Bergabung ke ruangan: " + ruangan);
        // simpan ruangan di user properties sesi
        sesi.getUserProperties().put("ruangan", ruangan);
    }

    @OnMessage
    public void onMessage(String pesan, Session pengirim) {
        String ruangan = (String) pengirim.getUserProperties().get("ruangan");
        System.out.println("[" + ruangan + "] " + pesan);
        // broadcast hanya ke klien di ruangan yang sama
    }
}

Spring Boot WebSocket #

Spring Boot menyederhanakan setup WebSocket dengan auto-configuration dan dukungan STOMP (Simple Text Oriented Messaging Protocol) — protokol messaging di atas WebSocket yang menambahkan konsep channel dan subscription.

Dependensi #

<!-- Maven -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-websocket'

WebSocket Sederhana (Tanpa STOMP) #

Pendekatan paling langsung: handler yang menangani pesan teks satu per satu.

import org.springframework.web.socket.*;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;

public class ChatWebSocketHandler extends TextWebSocketHandler {

    private static final Set<WebSocketSession> sesiAktif = new CopyOnWriteArraySet<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession sesi) throws Exception {
        sesiAktif.add(sesi);
        System.out.println("Terhubung: " + sesi.getId());
    }

    @Override
    protected void handleTextMessage(WebSocketSession pengirim, TextMessage pesan)
            throws IOException {
        String teks = pesan.getPayload();
        System.out.println("Diterima: " + teks);

        // Broadcast ke semua kecuali pengirim
        for (WebSocketSession s : sesiAktif) {
            if (s.isOpen() && !s.getId().equals(pengirim.getId())) {
                s.sendMessage(new TextMessage(teks));
            }
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession sesi, CloseStatus status) {
        sesiAktif.remove(sesi);
        System.out.println("Terputus: " + sesi.getId() + " — " + status);
    }

    @Override
    public void handleTransportError(WebSocketSession sesi, Throwable error) throws Exception {
        System.err.println("Error: " + error.getMessage());
        sesiAktif.remove(sesi);
        sesi.close(CloseStatus.SERVER_ERROR);
    }
}

Konfigurasi WebSocket #

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.*;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry
            .addHandler(new ChatWebSocketHandler(), "/ws/chat")
            .setAllowedOrigins("*"); // di produksi, batasi ke domain tertentu
    }
}

WebSocket dengan STOMP dan SockJS #

STOMP menambahkan abstraksi channel (topic dan queue) di atas WebSocket. SockJS adalah fallback — jika browser tidak mendukung WebSocket, ia otomatis beralih ke polling. Pendekatan ini adalah standar de facto di aplikasi Spring Boot.

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;

@Configuration
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // Endpoint handshake WebSocket, dengan SockJS sebagai fallback
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // Prefix untuk pesan yang dikirim klien ke server
        registry.setApplicationDestinationPrefixes("/app");

        // Prefix untuk topic broadcast — klien subscribe ke /topic/xxx
        registry.enableSimpleBroker("/topic", "/queue");
    }
}
import org.springframework.messaging.handler.annotation.*;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

@Controller
public class ChatController {

    private final SimpMessagingTemplate template;

    public ChatController(SimpMessagingTemplate template) {
        this.template = template;
    }

    // Klien kirim ke /app/pesan → controller ini memprosesnya
    @MessageMapping("/pesan")
    @SendTo("/topic/ruang-umum") // broadcast hasilnya ke semua subscriber topic ini
    public String tanganiPesan(String pesan) {
        return "Server: " + pesan;
    }

    // Kirim pesan ke topic secara programatik (misalnya dari job terjadwal)
    public void kirimNotifikasi(String notif) {
        template.convertAndSend("/topic/notifikasi", notif);
    }

    // Kirim ke satu pengguna spesifik
    public void kirimPrivat(String username, String pesan) {
        template.convertAndSendToUser(username, "/queue/pesan", pesan);
    }
}

Klien JavaScript (Browser) #

// Hubungkan ke server WebSocket Spring Boot
const socket = new WebSocket('ws://localhost:8080/ws/chat');

socket.onopen = () => {
    console.log('Terhubung!');
    socket.send('Halo dari browser');
};

socket.onmessage = (event) => {
    console.log('Diterima:', event.data);
};

socket.onclose = (event) => {
    console.log('Terputus:', event.code, event.reason);
};

socket.onerror = (error) => {
    console.error('Error WebSocket:', error);
};

// Kirim pesan
function kirim(pesan) {
    if (socket.readyState === WebSocket.OPEN) {
        socket.send(pesan);
    }
}

// Tutup koneksi
function tutup() {
    socket.close(1000, 'Pengguna keluar');
}

Jetty Embedded WebSocket #

Jetty memungkinkan membuat server WebSocket yang di-embed langsung dalam aplikasi Java biasa — tanpa perlu server aplikasi eksternal. Cocok untuk tool internal, aplikasi desktop, atau microservice ringan.

Dependensi #

<!-- Maven — Jetty 12 -->
<dependency>
    <groupId>org.eclipse.jetty</groupId>
    <artifactId>jetty-server</artifactId>
    <version>12.0.7</version>
</dependency>
<dependency>
    <groupId>org.eclipse.jetty.websocket</groupId>
    <artifactId>jetty-websocket-jetty-server</artifactId>
    <version>12.0.7</version>
</dependency>

Implementasi dengan Jetty #

import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.websocket.api.*;
import org.eclipse.jetty.websocket.api.annotations.*;
import org.eclipse.jetty.websocket.server.JettyWebSocketServlet;
import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory;

// Endpoint WebSocket
@WebSocket
public class EchoSocket {

    @OnWebSocketOpen
    public void onOpen(Session sesi) {
        System.out.println("Terhubung: " + sesi.getRemoteAddress());
        sesi.setIdleTimeout(java.time.Duration.ofMinutes(5));
    }

    @OnWebSocketMessage
    public void onMessage(Session sesi, String pesan) {
        System.out.println("Diterima: " + pesan);
        try {
            sesi.getRemote().sendString("Echo: " + pesan);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @OnWebSocketClose
    public void onClose(int statusCode, String alasan) {
        System.out.println("Ditutup: " + statusCode + " — " + alasan);
    }

    @OnWebSocketError
    public void onError(Throwable error) {
        System.err.println("Error: " + error.getMessage());
    }
}

// Servlet untuk mendaftarkan endpoint
public class EchoServlet extends JettyWebSocketServlet {
    @Override
    protected void configure(JettyWebSocketServletFactory factory) {
        factory.register(EchoSocket.class);
    }
}
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;

public class JettyMain {
    public static void main(String[] args) throws Exception {
        Server server = new Server(8080);

        ServletContextHandler ctx = new ServletContextHandler();
        ctx.setContextPath("/");
        ctx.addServlet(new ServletHolder(new EchoServlet()), "/ws/*");

        server.setHandler(ctx);
        server.start();

        System.out.println("Server Jetty berjalan di ws://localhost:8080/ws/");
        server.join();
    }
}

Heartbeat — Menjaga Koneksi Tetap Hidup #

Koneksi WebSocket bisa diputus secara diam-diam oleh proxy, load balancer, atau firewall yang menganggap koneksi idle sudah tidak aktif. Heartbeat (ping/pong) mencegah ini dengan mengirim frame kecil secara periodik.

Heartbeat di Jakarta EE #

import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.concurrent.*;

@ServerEndpoint("/ws/live")
public class LiveEndpoint {

    private ScheduledExecutorService scheduler;
    private ScheduledFuture<?> heartbeatTask;

    @OnOpen
    public void onOpen(Session sesi) {
        scheduler = Executors.newSingleThreadScheduledExecutor();

        // Kirim ping setiap 30 detik
        heartbeatTask = scheduler.scheduleAtFixedRate(() -> {
            if (sesi.isOpen()) {
                try {
                    // Frame ping — server mengirim, klien otomatis balas dengan pong
                    sesi.getBasicRemote().sendPing(ByteBuffer.wrap("ping".getBytes()));
                } catch (IOException e) {
                    System.err.println("Ping gagal: " + e.getMessage());
                }
            }
        }, 30, 30, TimeUnit.SECONDS);
    }

    @OnClose
    public void onClose(Session sesi) {
        if (heartbeatTask != null) heartbeatTask.cancel(true);
        if (scheduler != null) scheduler.shutdown();
    }

    @OnMessage
    public void onMessage(String pesan, Session sesi) {
        // handle pesan normal
    }
}

Heartbeat di Spring Boot #

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;

@Configuration
@EnableWebSocketMessageBroker
public class StompConfigDenganHeartbeat implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // Heartbeat: server kirim setiap 10 detik, harapkan klien kirim setiap 10 detik
        registry.enableSimpleBroker("/topic")
                .setHeartbeatValue(new long[]{10000, 10000});
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }
}

Manajemen Sesi dan Keamanan #

Menyimpan Informasi Pengguna di Sesi #

@ServerEndpoint("/ws/chat")
public class ChatEndpointDenganAuth {

    @OnOpen
    public void onOpen(Session sesi,
                       @jakarta.websocket.server.PathParam("token") String token) {
        // Validasi token
        String username = validasiToken(token);
        if (username == null) {
            try {
                sesi.close(new CloseReason(
                    CloseReason.CloseCodes.VIOLATED_POLICY,
                    "Token tidak valid"
                ));
            } catch (IOException e) {
                e.printStackTrace();
            }
            return;
        }

        // Simpan info pengguna di properti sesi
        sesi.getUserProperties().put("username", username);
        System.out.println(username + " terhubung");
    }

    @OnMessage
    public void onMessage(String pesan, Session sesi) {
        String username = (String) sesi.getUserProperties().get("username");
        if (username != null) {
            broadcast("[" + username + "]: " + pesan);
        }
    }

    private String validasiToken(String token) {
        // Implementasi validasi JWT atau session token
        return token != null && !token.isBlank() ? "user-" + token : null;
    }

    private void broadcast(String pesan) {
        // ... broadcast ke semua sesi
    }
}

Anti-Pattern Umum #

// ANTI-PATTERN 1: kirim pesan tanpa cek apakah sesi masih terbuka
sesi.getBasicRemote().sendText(pesan); // ✗ bisa throw jika sesi sudah tutup

// BENAR: selalu cek isOpen() sebelum kirim
if (sesi.isOpen()) {
    try {
        sesi.getBasicRemote().sendText(pesan);
    } catch (IOException e) {
        sesiAktif.remove(sesi); // hapus dari daftar jika gagal kirim
    }
}

// ANTI-PATTERN 2: gunakan getBasicRemote() di server dengan banyak klien
// getBasicRemote() adalah blocking — satu pesan lambat memblokir yang lain
for (Session s : sesiAktif) {
    s.getBasicRemote().sendText(pesan); // ✗ blocking, lambat

// BENAR: gunakan getAsyncRemote() untuk broadcast
for (Session s : sesiAktif) {
    if (s.isOpen()) {
        s.getAsyncRemote().sendText(pesan); // ✓ non-blocking
    }
}

// ANTI-PATTERN 3: menyimpan sesi di variabel instance (tidak thread-safe)
public class EndpointBuruk {
    private Session sesi; // ✗ setiap instance adalah satu sesi — ini sebenarnya ok
    // tapi jika ada state yang di-share antar sesi, harus static + thread-safe
}

Kapan Menggunakan WebSocket #

Gunakan WEBSOCKET jika:
  ✓ Data harus mengalir dari server ke klien tanpa klien meminta (server push)
  ✓ Latensi rendah sangat penting (game, trading, kolaborasi real-time)
  ✓ Frekuensi update tinggi (lebih dari sekali per detik)
  ✓ Komunikasi dua arah berkelanjutan diperlukan

Gunakan HTTP biasa jika:
  ✗ Data hanya dibutuhkan sesekali (kurang dari satu kali per menit)
  ✗ Operasi stateless sederhana (CRUD API)
  ✗ Caching di sisi klien atau CDN penting
  ✗ Tim belum familiar dengan konsep stateful connection

Pertimbangkan SSE (Server-Sent Events) jika:
  → Hanya butuh server push satu arah (stream notifikasi, progress)
  → Lebih sederhana dari WebSocket karena hanya HTTP biasa
  → Otomatis reconnect built-in di browser

Pilih implementasi:
  → Jakarta EE WebSocket  : sudah punya app server (Tomcat, WildFly, Payara)
  → Spring Boot WebSocket : proyek Spring Boot, butuh STOMP atau integrasi Spring
  → Jetty embedded        : aplikasi standalone tanpa app server

Ringkasan #

  • WebSocket dimulai dari HTTP lalu di-upgrade — handshake menggunakan HTTP 101 Switching Protocols. Setelah itu, koneksi TCP yang sama dipakai untuk frame WebSocket dua arah.
  • Full-duplex berarti server bisa kirim kapan saja — tidak perlu menunggu request dari klien. Ini yang membedakan WebSocket dari HTTP polling.
  • Jakarta EE WebSocket menggunakan anotasi@ServerEndpoint, @OnOpen, @OnMessage, @OnClose, @OnError mendefinisikan lifecycle endpoint dengan deklaratif.
  • CopyOnWriteArraySet untuk daftar sesi aktif — thread-safe dan aman diiterasi saat ada penambahan atau penghapusan dari thread lain.
  • Gunakan getAsyncRemote() untuk broadcastgetBasicRemote() adalah blocking. Untuk mengirim ke banyak klien sekaligus, getAsyncRemote() tidak memblokir thread.
  • Selalu cek sesi.isOpen() sebelum kirim — sesi bisa ditutup kapan saja. Kirim ke sesi yang sudah tutup akan melempar exception.
  • Heartbeat mencegah pemutusan koneksi idle — proxy dan firewall sering memutus koneksi yang tidak aktif. Kirim ping setiap 30–60 detik untuk menjaga koneksi tetap hidup.
  • Spring Boot + STOMP untuk aplikasi enterprise: menambahkan konsep topic, queue, dan subscription di atas WebSocket sehingga routing pesan menjadi lebih terstruktur.

← Sebelumnya: Socket   Berikutnya: Web Server →

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