Sockets #
A socket is a low-level interface for network communication — it gives you full control over how data is sent and received between processes, whether on the same machine or across different machines. Before HTTP, gRPC, or WebSocket existed, all network communication was built on top of sockets. Understanding sockets gives you the foundation to understand how those higher-level protocols work underneath. Kotlin uses Java’s mature, battle-tested socket API, but it can be combined with coroutines for a much cleaner concurrency model. This article covers TCP and UDP sockets, multi-client servers, custom protocols, TLS security, and patterns used in production.
TCP vs UDP — Choosing the Right Protocol #
Sockets come in two main “flavors” that reflect two different philosophies of data delivery:
flowchart LR
A[Sender] -- "TCP\nConnection handshake\nGuaranteed ordering\nAutomatic retransmission\nSlower" --> B[Receiver]
C[Sender] -- "UDP\nNo connection\nOrder not guaranteed\nNo retries\nFaster" --> D[Receiver]| Aspect | TCP | UDP |
|---|---|---|
| Connection | Needs a handshake (connect) | Send directly |
| Data ordering | Guaranteed | Not guaranteed |
| Retransmission | Automatic if a packet is lost | None |
| Overhead | Larger | Minimal |
| Best for | HTTP, databases, file transfer | Video streaming, DNS, real-time games |
TCP Socket — Basic Server #
ServerSocket waits for incoming connections on a specific port. Each accept() blocks until a client connects.
import java.net.ServerSocket
import java.net.Socket
import java.io.IOException
fun main() {
val port = 9999
ServerSocket(port).use { serverSocket ->
println("Server running on port $port")
println("Waiting for connections...")
// Infinite loop — accept clients one at a time (single-threaded)
while (true) {
val clientSocket = serverSocket.accept()
val clientAddress = clientSocket.inetAddress.hostAddress
println("Client connected from: $clientAddress")
// Handle the client
handleClient(clientSocket)
}
}
}
fun handleClient(socket: Socket) {
socket.use { s ->
val reader = s.getInputStream().bufferedReader()
val writer = s.getOutputStream().bufferedWriter()
// Read a message from the client
val message = reader.readLine()
println("Received: $message")
// Send a response
writer.write("Server received: $message\n")
writer.flush()
}
println("Connection closed")
}
The server above is single-threaded — it can only serve one client at a time. A second client must wait for the first to finish. For a production server, you need multi-threading or coroutines.
TCP Socket — Client #
import java.net.Socket
import java.net.ConnectException
import java.net.SocketTimeoutException
fun main() {
val host = "localhost"
val port = 9999
try {
Socket(host, port).use { socket ->
// Set a timeout — don't let the socket wait forever
socket.soTimeout = 5000 // 5 second read timeout
val writer = socket.getOutputStream().bufferedWriter()
val reader = socket.getInputStream().bufferedReader()
// Send a message
val message = "Hello from client!"
writer.write("$message\n")
writer.flush()
println("Sent: $message")
// Read the response
val response = reader.readLine()
println("Server response: $response")
}
} catch (e: ConnectException) {
println("Failed to connect to $host:$port — is the server running?")
} catch (e: SocketTimeoutException) {
println("Timeout: the server didn't respond within 5 seconds")
} catch (e: IOException) {
println("Network error: ${e.message}")
}
}
Multi-Client Server with Threads #
A real server needs to handle many clients simultaneously. The classic approach: one thread per client.
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
class MultiClientServer(private val port: Int) {
private val activeClientCount = AtomicInteger(0)
private val executor = Executors.newCachedThreadPool()
fun start() {
ServerSocket(port).use { serverSocket ->
println("Multi-client server running on port $port")
while (true) {
val clientSocket = serverSocket.accept()
val clientId = activeClientCount.incrementAndGet()
executor.submit {
handleClient(clientSocket, clientId)
activeClientCount.decrementAndGet()
}
}
}
}
private fun handleClient(socket: Socket, id: Int) {
val address = socket.inetAddress.hostAddress
println("[Client #$id] Connected from $address")
socket.use { s ->
val reader = s.getInputStream().bufferedReader()
val writer = s.getOutputStream().bufferedWriter()
// Session loop — one client can send many messages
try {
while (true) {
val message = reader.readLine() ?: break // null = connection closed
println("[Client #$id] Message: $message")
if (message.lowercase() == "quit") {
writer.write("Goodbye!\n")
writer.flush()
break
}
val response = processCommand(message)
writer.write("$response\n")
writer.flush()
}
} catch (e: IOException) {
println("[Client #$id] Connection lost: ${e.message}")
}
}
println("[Client #$id] Session ended. Active clients: ${activeClientCount.get()}")
}
private fun processCommand(message: String): String {
return when (message.uppercase()) {
"PING" -> "PONG"
"TIME" -> "Server time: ${java.time.LocalTime.now()}"
"INFO" -> "Active clients: ${activeClientCount.get()}"
else -> "Echo: $message"
}
}
}
fun main() {
MultiClientServer(9999).start()
}
Multi-Client Server with Coroutines #
The coroutine approach is more efficient than a thread pool for servers with many concurrent connections — each connection only consumes very small coroutine memory, not a 1MB thread stack.
import kotlinx.coroutines.*
import java.net.ServerSocket
import java.net.Socket
fun main() = runBlocking {
val port = 9999
val serverSocket = ServerSocket(port)
println("Coroutine server running on port $port")
// Dedicated dispatcher for blocking I/O
withContext(Dispatchers.IO) {
while (isActive) {
// accept() is a blocking operation — runs on the IO dispatcher
val clientSocket = serverSocket.accept()
// Each client gets its own coroutine
launch {
handleClientCoroutine(clientSocket)
}
}
}
}
suspend fun handleClientCoroutine(socket: Socket) {
val address = socket.inetAddress.hostAddress
println("Client connected: $address")
withContext(Dispatchers.IO) {
socket.use { s ->
val reader = s.getInputStream().bufferedReader()
val writer = s.getOutputStream().bufferedWriter()
try {
var line: String?
while (reader.readLine().also { line = it } != null) {
val message = line ?: break
println("[$address] $message")
writer.write("Echo: $message\n")
writer.flush()
if (message == "BYE") break
}
} catch (e: Exception) {
println("[$address] Error: ${e.message}")
}
}
}
println("[$address] Disconnected")
}
Custom Text Protocols #
For more complex applications, you need to define your own communication protocol — the rules for how clients and servers exchange messages. Example: a simple chat protocol.
// Protocol:
// LOGIN:<name> → server replies ACCEPTED or REJECTED
// MESSAGE:<text> → server forwards to all other clients
// LIST → server replies with the active user list
// EXIT → close the connection
import java.net.ServerSocket
import java.net.Socket
import java.io.BufferedWriter
import java.util.concurrent.ConcurrentHashMap
object ChatServer {
private val activeClients = ConcurrentHashMap<String, BufferedWriter>()
fun start(port: Int) {
ServerSocket(port).use { server ->
println("Chat server running on port $port")
while (true) {
val socket = server.accept()
kotlin.concurrent.thread(isDaemon = true) {
handleClient(socket)
}
}
}
}
private fun handleClient(socket: Socket) {
var username: String? = null
socket.use { s ->
val reader = s.getInputStream().bufferedReader()
val writer = s.getOutputStream().bufferedWriter()
fun send(message: String) {
writer.write("$message\n")
writer.flush()
}
fun broadcast(message: String, except: String? = null) {
activeClients.forEach { (name, w) ->
if (name != except) {
runCatching { w.write("$message\n"); w.flush() }
}
}
}
try {
var line: String?
while (reader.readLine().also { line = it } != null) {
val input = line ?: break
val (command, argument) = if (":" in input) {
input.substringBefore(":") to input.substringAfter(":")
} else {
input to ""
}
when (command.uppercase()) {
"LOGIN" -> {
val name = argument.trim()
if (name.isBlank() || activeClients.containsKey(name)) {
send("REJECTED:Name invalid or already in use")
} else {
username = name
activeClients[name] = writer
send("ACCEPTED:Welcome, $name!")
broadcast("INFO:$name joined the chat", except = name)
println("[$name] Joined")
}
}
"MESSAGE" -> {
val name = username ?: run { send("ERROR:Not logged in"); return@while }
broadcast("MESSAGE:$name: $argument")
println("[$name] $argument")
}
"LIST" -> {
val list = activeClients.keys.joinToString(", ")
send("LIST:$list")
}
"EXIT" -> {
send("BYE:Goodbye!")
break
}
else -> send("ERROR:Unknown command: $command")
}
}
} catch (e: Exception) {
println("[${username ?: "?"}] Error: ${e.message}")
} finally {
username?.let { name ->
activeClients.remove(name)
broadcast("INFO:$name left the chat")
println("[$name] Exited")
}
}
}
}
}
fun main() {
ChatServer.start(9999)
}
UDP Sockets #
UDP (User Datagram Protocol) doesn’t require a connection — data packets (datagrams) are sent directly without a handshake. Suitable for applications that tolerate packet loss but need low latency.
import java.net.DatagramSocket
import java.net.DatagramPacket
import java.net.InetAddress
// UDP Server
fun udpServer(port: Int) {
DatagramSocket(port).use { socket ->
println("UDP Server running on port $port")
val buffer = ByteArray(1024)
while (true) {
val packet = DatagramPacket(buffer, buffer.size)
socket.receive(packet) // blocks until a datagram arrives
val message = String(packet.data, 0, packet.length)
println("Received from ${packet.address}:${packet.port}: $message")
// Send a response back to the sender
val response = "Pong: $message".toByteArray()
val responsePacket = DatagramPacket(
response, response.size,
packet.address, packet.port
)
socket.send(responsePacket)
}
}
}
// UDP Client
fun udpClient(host: String, port: Int) {
DatagramSocket().use { socket ->
socket.soTimeout = 3000 // 3 second timeout
val message = "Ping from client"
val data = message.toByteArray()
val serverAddress = InetAddress.getByName(host)
// Send the datagram
val sendPacket = DatagramPacket(data, data.size, serverAddress, port)
socket.send(sendPacket)
println("Sent: $message")
// Receive the response
val buffer = ByteArray(1024)
val receivePacket = DatagramPacket(buffer, buffer.size)
socket.receive(receivePacket)
val response = String(receivePacket.data, 0, receivePacket.length)
println("Response: $response")
}
}
Sockets with TLS/SSL #
For secure communication, use SSLSocket which encrypts all data sent.
import javax.net.ssl.SSLServerSocketFactory
import javax.net.ssl.SSLSocketFactory
// SSL Server — needs a keystore with a certificate
fun sslServer(port: Int) {
// Keystore configuration (usually via System properties or an SSLContext)
System.setProperty("javax.net.ssl.keyStore", "server.jks")
System.setProperty("javax.net.ssl.keyStorePassword", "password")
val factory = SSLServerSocketFactory.getDefault()
factory.createServerSocket(port).use { server ->
println("SSL Server running on port $port")
while (true) {
val socket = server.accept()
kotlin.concurrent.thread {
socket.use { s ->
val reader = s.getInputStream().bufferedReader()
val writer = s.getOutputStream().bufferedWriter()
val message = reader.readLine()
println("SSL: received '$message'")
writer.write("SSL: echo '$message'\n")
writer.flush()
}
}
}
}
}
// SSL Client
fun sslClient(host: String, port: Int) {
System.setProperty("javax.net.ssl.trustStore", "client.jks")
System.setProperty("javax.net.ssl.trustStorePassword", "password")
val factory = SSLSocketFactory.getDefault()
factory.createSocket(host, port).use { socket ->
val writer = socket.getOutputStream().bufferedWriter()
val reader = socket.getInputStream().bufferedReader()
writer.write("Secret message\n")
writer.flush()
println(reader.readLine())
}
}
Important Socket Configurations #
import java.net.Socket
fun configureSocket(socket: Socket) {
// Timeout for read operations — important so it doesn't hang forever
socket.soTimeout = 30_000 // 30 seconds
// TCP_NODELAY — disable Nagle's algorithm for low latency
// Useful for request-response protocols that often send small packets
socket.tcpNoDelay = true
// Keep-alive — periodically send probes to detect dead connections
// Important for connections that may idle for a long time
socket.keepAlive = true
// Buffer sizes — adjust according to throughput needs
socket.receiveBufferSize = 64 * 1024 // 64KB receive buffer
socket.sendBufferSize = 64 * 1024 // 64KB send buffer
// Linger — wait for data transmission to finish before close()
socket.setSoLinger(true, 5) // wait at most 5 seconds
}
// ServerSocket options
fun configureServerSocket(serverSocket: java.net.ServerSocket) {
// REUSE_ADDRESS — allow binding to a recently used port
// Important so the server can restart fast without "Address already in use"
serverSocket.reuseAddress = true
// Backlog — how many connections can be queued before accept()
// ServerSocket(port, backlog = 50)
}
Production Tips #
Some things to keep in mind when running a socket server in production:
// 1. Always set a timeout — don't let connections hang forever
socket.soTimeout = 30_000
// 2. Use use{} or try-finally to make sure the socket is always closed
socket.use { s ->
// operations...
}
// 3. Log useful information for debugging
println("[${socket.inetAddress.hostAddress}:${socket.port}] Client connected")
// 4. Limit the size of received messages — prevent memory overflow from malicious clients
val message = reader.readLine()?.take(4096) // max 4KB per line
// 5. Use an AtomicInteger for a thread-safe client counter
val clientCount = AtomicInteger(0)
// 6. Handle graceful server shutdown
Runtime.getRuntime().addShutdownHook(Thread {
println("Server shutting down, closing all connections...")
serverSocket.close()
})
Summary #
- TCP for reliability, UDP for speed — choose TCP when ordering and reliability matter (HTTP, databases, file transfer). Choose UDP when low latency matters more than reliability (video streaming, games, DNS).
use {}for all sockets —Socket,ServerSocket, andDatagramSocketare allCloseable. Always wrap them withuse {}so they’re definitely closed even when an exception occurs.- Set
soTimeouton every socket — without a timeout,read()can block forever if the client doesn’t send data. This leaves a thread/coroutine hanging with no way to free it.- Single-threaded servers are for demos only — in production, use a thread pool (
Executors.newCachedThreadPool()) or coroutines (launch {}inDispatchers.IO) to serve many clients concurrently.- Coroutines are more efficient than one thread per client — one coroutine consumes far less memory than one OS thread. For servers with thousands of concurrent connections, coroutines are the right choice.
- Define the protocol clearly — for complex TCP servers, decide the message format, delimiter, and command set from the start. Use a
COMMAND:ARGUMENT\nformat or binary with a length header.reuseAddress = trueon ServerSocket — without this, restarting a server after a crash can fail with “Address already in use” because the port is still in TIME_WAIT status.- Use TLS/SSL for security — data sent via plain sockets can be intercepted.
SSLSocketencrypts all data transparently without significantly changing application logic.