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 #
| Frame | Kode | Kegunaan |
|---|---|---|
| Text | 0x1 | Pesan teks (UTF-8) |
| Binary | 0x2 | Data biner |
| Close | 0x8 | Menutup koneksi dengan kode dan alasan |
| Ping | 0x9 | Cek koneksi masih hidup |
| Pong | 0xA | Balas 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 ditutupWebSocket vs HTTP Polling #
| Aspek | HTTP Polling | WebSocket |
|---|---|---|
| Koneksi | Baru setiap request | Satu koneksi persisten |
| Latensi | Tinggi (overhead HTTP header) | Rendah (langsung kirim frame) |
| Server push | ✗ Tidak bisa | ✓ Bisa kapan saja |
| Beban server | Tinggi (banyak request) | Rendah |
| Kasus terbaik | Data jarang berubah | Data 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,@OnErrormendefinisikan lifecycle endpoint dengan deklaratif.CopyOnWriteArraySetuntuk daftar sesi aktif — thread-safe dan aman diiterasi saat ada penambahan atau penghapusan dari thread lain.- Gunakan
getAsyncRemote()untuk broadcast —getBasicRemote()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.