Building a Simple Synchronous TCP Echo Server: Foundations of Redis Internals

Arpit Bhayani

Arpit Bhayani

Oct 14, 2022 • 8 min read

Play

Building a Simple Synchronous TCP Echo Server: Foundations of Redis Internals

When designing an in-memory database like Redis or DiceDB, everything begins at the transport layer. High-performance databases do not expose high-overhead application-layer protocols like HTTP/REST for core data operations; they operate directly over raw TCP sockets using a lightweight, custom serialization protocol.

Before implementing key-value storage engines, eviction policies, or multi-threading, the foundational step (Step 0) is understanding the network boundary. This guide walks through constructing a synchronous TCP echo server from scratch in Go, dissecting the low-level socket lifecycle, analyzing the constraints of synchronous blocking I/O, and observing how redis-cli interacts over a raw TCP connection via the Redis Serialization Protocol (RESP).


1. High-Level Architecture & Lifecycle

A baseline TCP server operates through standard POSIX socket primitives:

  1. Bind & Listen: Bind a socket to a network interface (host:port) and mark it as passive to listen for incoming connection requests.
  2. Accept Loop: Enter a loop and invoke accept(), blocking the thread until a client completes the TCP three-way handshake.
  3. Connection Handling Loop: Read bytes sent across the accepted connection socket, process the payload, and write back the response.
  4. Close / Cleanup: Handle EOF or socket errors by closing the connection and releasing file descriptors.
sequenceDiagram
    autonumber
    actor Client as Client (netcat / redis-cli)
    participant Kernel as OS TCP Stack
    participant Server as Synchronous Server Thread

    Server->>Kernel: net.Listen("tcp", "0.0.0.0:7379")
    Server->>Kernel: listener.Accept() [Blocks]
    Note over Server,Kernel: Server thread suspended waiting for connection

    Client->>Kernel: TCP Handshake (SYN, SYN-ACK, ACK)
    Kernel-->>Server: Return active client socket (net.Conn)
    Note over Server: Inner Loop: readCommand() [Blocks]

    Client->>Server: Send payload ("hello\n")
    Server->>Server: Process payload
    Server->>Client: Send response ("hello\n")

    Client->>Kernel: Close socket (FIN / RST)
    Kernel-->>Server: Read returns EOF / err
    Server->>Server: Close connection, decrement client counter
    Server->>Kernel: Loop back to listener.Accept()

2. Implementing the Synchronous Echo Server in Go

Writing this server without third-party dependencies ensures complete transparency over the operating system system calls and socket lifecycles.

Step 1: Configuration and Flags

Database servers parameterize their listening endpoint. Redis defaults to port 6379; in this setup (mirroring DiceDB), port 7379 is used.

package main

import (
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"strconv"
)

func setupFlags() (string, int) {
	host := flag.String("host", "0.0.0.0", "Host interface to listen on")
	port := flag.Int("port", 7379, "Port to listen on")
	flag.Parse()
	return *host, *port
}
  • 0.0.0.0 represents INADDR_ANY, instructing the operating system to bind to all available network interfaces (local loopback, private subnet, and public IP).

Step 2: The Core Server Loop

The server maintains a synchronous lifecycle using nested infinite loops:

func runSynchronousTCPServer(host string, port int) {
	address := host + ":" + strconv.Itoa(port)
	listener, err := net.Listen("tcp", address)
	if err != nil {
		log.Fatalf("Failed to bind to %s: %v", address, err)
	}
	defer listener.Close()

	log.Printf("Starting synchronous TCP server on %s\n", address)
	concurrentClients := 0

	for {
		// Blocking Call: Waits for an incoming TCP connection
		conn, err := listener.Accept()
		if err != nil {
			log.Printf("Error accepting connection: %v", err)
			continue
		}

		concurrentClients++
		log.Printf("Client connected: %s | Concurrent clients: %d\n",
			conn.RemoteAddr().String(), concurrentClients)

		// Inner Connection Loop: Synchronously service ONLY this client
		for {
			cmd, err := readCommand(conn)
			if err != nil {
				conn.Close()
				concurrentClients--
				if err == io.EOF {
					log.Printf("Client disconnected gracefully: %s | Concurrent clients: %d\n",
						conn.RemoteAddr().String(), concurrentClients)
				} else {
					log.Printf("Connection error with %s: %v | Concurrent clients: %d\n",
						conn.RemoteAddr().String(), err, concurrentClients)
				}
				break // Break inner loop to accept next connection
			}

			log.Printf("Received command: %q", cmd)
			respond(conn, cmd)
		}
	}
}

Step 3: Low-Level Read and Write Operations

func readCommand(conn net.Conn) (string, error) {
	// Fixed-size buffer to capture incoming TCP stream bytes
	buf := make([]byte, 1024)
	
	// Blocking system call: halts until bytes arrive or connection closes
	n, err := conn.Read(buf)
	if err != nil {
		return "", err
	}
	return string(buf[:n]), nil
}

func respond(conn net.Conn, response string) error {
	// Echo identical payload back across the socket
	_, err := conn.Write([]byte(response))
	return err
}

func main() {
	host, port := setupFlags()
	runSynchronousTCPServer(host, port)
}

3. The Mechanics of Blocking I/O

This simple implementation exposes critical architectural trade-offs that dictate how databases and network servers are engineered.

The Double Loop Bottleneck

  1. Outer Loop (listener.Accept()): Suspends the thread until the kernel hands over a fully established socket from the TCP listen backlog.
  2. Inner Loop (conn.Read()): Suspends the thread until data frames arrive on that specific socket.

Because both operations run on the same single thread of execution, the server cannot execute listener.Accept() while it is trapped in the inner readCommand() loop.

+-------------------------------------------------------------+
|                      Main Thread Flow                       |
+-------------------------------------------------------------+
                               |
                               v
                  +-------------------------+
                  |    listener.Accept()    |<-----------------+
                  +-------------------------+                  |
                               | (Client 1 connects)           |
                               v                               |
    +-----------------------------------------------------+    |
    |             INNER LOOP (Client 1 ONLY)              |    |
    |                                                     |    |
    |  readCommand(conn) <-----+                          |    |
    |    | (Blocks for input)  |                          |    |
    |    v                     | (Repeats until EOF)      |    |
    |  respond(conn) ----------+                          |    |
    +-----------------------------------------------------+    |
                               | (Client 1 disconnects)        |
                               v                               |
                  +-------------------------+                  |
                  |       conn.Close()      |                  |
                  +-------------------------+                  |
                               |                               |
                               +-------------------------------+

Verifying Connection Starvation (Hands-on)

Running two concurrent terminal sessions demonstrates this behavior directly:

  1. Client 1 connects:

    nc localhost 7379

    Server output: Client connected: 127.0.0.1:54321 | Concurrent clients: 1 Client 1 sends hello and immediately receives hello.

  2. Client 2 attempts to connect in a separate terminal:

    nc localhost 7379

    Server output: No output. The server does not print a connection log for Client 2. If Client 2 types ping, no response is received. The connection is held in the operating system’s TCP syn-backlog / accept-queue, but the application space cannot call accept() to consume it.

  3. Client 1 disconnects (Ctrl+C): Server output:

    Client disconnected gracefully: 127.0.0.1:54321 | Concurrent clients: 0
    Client connected: 127.0.0.1:54322 | Concurrent clients: 1
    Received command: "ping\n"

    The moment Client 1 releases the thread, the inner loop terminates, the outer loop advances to listener.Accept(), retrieves Client 2 from the kernel queue, and processes its pending input.


4. Peeking into Redis Internals: Connecting with redis-cli

Connecting standard tools to this raw echo server reveals what happens under the hood of real database drivers.

Run the official Redis CLI against this raw TCP server:

redis-cli -p 7379

The echo server immediately logs the raw bytes transmitted by redis-cli upon connection:

Received command: "*1\r\n$7\r\nCOMMAND\r\n"

When you issue a command inside redis-cli like PUT key value:

127.0.0.1:7379> PUT key value

The server captures and logs:

Received command: "*3\r\n$3\r\nPUT\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"

Decoding RESP (Redis Serialization Protocol)

Redis does not parse plain whitespace-delimited text. It operates on RESP (Redis Serialization Protocol), a human-readable, binary-safe serialization specification:

Token PrefixTypeExampleExplanation
*Array`*3
`Array containing 3 elements
$Bulk String`$3
PUT
`Bulk string of length 3 bytes followed by data and `
`
+Simple String`+OK
`Success response message
-Error`-ERR unknown
`Error message from server
:Integer`:1000
`64-bit signed integer

When redis-cli connects, it first sends *1 $7 COMMAND (an array of 1 element, which is the bulk string COMMAND) to discover the server’s supported command set.

Because the echo server returns the exact raw string rather than a valid RESP response (such as a valid RESP Array or Error), redis-cli cannot parse the reply. However, this interaction proves that any Redis-compatible server is simply a TCP server capable of serializing and deserializing RESP packets over raw sockets.


5. Architectural Comparison: Concurrency Models

A synchronous, single-connection TCP server is unviable for production systems. Server architectures generally evolve across three models:

ArchitectureMechanismStrengthsTrade-offs
Synchronous Single-Threaded (Implemented above)One thread for both accept() and read()/write().Extremely simple; zero race conditions.Head-of-line blocking; maximum concurrency is exactly 1.
Thread-per-ConnectionSpawn a new OS thread or Go goroutine (go handleConnection(conn)) on each accept().Simple programming model; full multi-client concurrency.High memory overhead per thread/stack; high context-switching overhead under tens of thousands of connections.
Event-Driven Non-Blocking I/O (Real Redis model)Single thread with OS multiplexers (epoll on Linux, kqueue on macOS, io_uring).Ultra-high throughput; thousands of connections with minimal memory; zero thread synchronization locks.Complex state machines; long-running operations can block the entire event loop.

Summary & Next Steps

  1. TCP Sockets Form the Base: Network communication for databases relies on basic transport layer socket primitives: bind, listen, accept, read, and write.
  2. Blocking Calls Cause Starvation: A synchronous server halts execution on conn.Read(), leaving all other clients stuck in the OS connection backlog until the active client drops.
  3. RESP Over TCP: Redis client libraries communicate by structuring commands into arrays of bulk strings (*<count> $<len> ...).

The immediate next step in building a complete Redis clone is writing a robust parser for the Redis Serialization Protocol (RESP) to decode arrays, integers, bulk strings, and command verbs sent over the wire.

Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses