A key-value store sounds simple: accept a command, look up a key, return a value. The interesting work starts when those commands have to cross a socket. A server needs a wire format, a parser that can handle input arriving in pieces, and a clear answer to the question of who owns the data after a command has been processed.
I implemented a tiny version of Redis to make those pieces small enough to see at once. It is a single-threaded C server that speaks a useful subset of Redis’s RESP protocol and supports PING, ECHO, SET, and GET. The implementation is deliberately limited, but it has the same basic shape as a real network service: create a listening socket, accept a connection, parse requests, execute commands, serialize replies, and manage memory that outlives an individual request.
I used Beej’s Guide to Network Programming while working through the socket lifecycle and the behavior of send() and recv(). I enjoyed his writing style too. One lesson from the guide becomes central here: a stream socket carries bytes in order, but it does not preserve the boundaries between application messages. The application protocol has to define those boundaries, and the server has to find them.
The socket setup is the scaffold
The server follows the familiar socket sequence from the guide: create a socket, bind it to an address, listen, then accept a client. This tiny version of Redis uses an IPv4 stream socket bound to 127.0.0.1:6379. The listening descriptor stays open; accept() returns a second descriptor for the connected client, and that is the one used for recv() and send().
socket() -> bind(127.0.0.1:6379) -> listen()
|
v
accept()
|
client_fd: recv() / send()
The code uses blocking calls. It accepts a client, handles commands on that connection until it closes, then returns to accept() for the next one. The listen backlog can queue pending connections, but the server does not process them concurrently.
A command is a small wire format
Redis clients do not send a C string such as SET greeting hello. They send a RESP-encoded array of strings. For example, this is a SET planet earth request:
*3\r\n
$3\r\nSET\r\n
$6\r\nplanet\r\n
$5\r\nearth\r\n
The *3 says that the array has three elements. Each $n introduces a bulk string with n bytes of payload, followed by \r\n. That length prefix is important: the parser can locate the end of a value without searching the value itself for a delimiter.
So this implementation parses the array header, then parses each bulk string into a Command structure:
typedef struct {
int argc;
char argv[MAX_ARGS][MAX_ARG_SIZE];
} Command;
The command is intentionally bounded: at most four arguments, each shorter than 100 bytes. parse_command() also receives the number of bytes currently available, and returns one of three statuses:
typedef enum {
PARSE_INVALID,
PARSE_INCOMPLETE,
PARSE_COMPLETE
} ParseStatus;
That distinction changes how the server reacts. Invalid input gets an error response and closes the client connection. Incomplete input is normal: it means the rest of the command may arrive in a later recv(), so the server should wait for more bytes. A complete command comes with a consumed count, telling the caller exactly how far to advance in the input buffer.
Passing an explicit length through the parser is a useful habit in C. The bytes received from a socket are not automatically a NUL-terminated string, and parsing them with string functions before adding a terminator would read past the available data. Here, input_len is the boundary; each parser checks that enough bytes exist before looking at a later field.
TCP does not deliver commands
The first prototype, nc_server.c, reads once from the client, tries to interpret that input, sends a reply, and closes the connection. That is a fine first step for understanding socket(), bind(), listen(), accept(), recv(), and send(). It is not enough for a protocol client: the read might contain only part of a command, and it might also contain several commands at once.
With TCP, a call to send() writes bytes to a stream. The receiving side may get those bytes in one recv() call or several, and one recv() can include the tail of one command plus the start of another. RESP’s length fields make the byte stream parseable, but they do not make the transport message-oriented.
The main server keeps a buffer and repeatedly tries to parse complete commands from it. The core loop looks like this:
/* close_client was initialized when this client was accepted. */
size_t processed = 0;
while (processed < buffered) {
Command *command = NULL;
size_t consumed = 0;
ParseStatus status = parse_command(
input_buffer + processed,
buffered - processed,
&command,
&consumed
);
if (status == PARSE_INCOMPLETE) break;
if (status == PARSE_INVALID) {
const char *error = "-ERR invalid RESP command\r\n";
send_all(client_fd, error, strlen(error));
close_client = 1;
break;
}
handle_command(db, command, output_buffer);
free(command);
if (!send_all(client_fd, output_buffer, strlen(output_buffer))) {
close_client = 1;
break;
}
processed += consumed;
}
if (processed > 0) {
memmove(input_buffer, input_buffer + processed, buffered - processed);
buffered -= processed;
}
If parsing stops because the final command is incomplete, its bytes stay at the beginning of the buffer. The next recv() appends more bytes after them. If there were several commands in the buffer, the loop handles each complete one and advances by its own consumed length. This is what lets one connection carry repeated commands without assuming one network read per request.
The input buffer has a fixed size of 1024 bytes. When it fills without the parser making progress, the server returns a command-too-large error and closes the connection. The fixed limits keep the code manageable, but they are protocol limits the server has chosen, not limits imposed by TCP.
A successful send() may still be partial
The response path has a similar boundary to respect. A call to send() can write fewer bytes than requested, so the caller must retry with the unsent suffix. Beej’s guide covers this case directly; my version wraps it in send_all():
int send_all(int fd, const char *buffer, size_t length) {
size_t sent = 0;
while (sent < length) {
ssize_t result = send(fd, buffer + sent, length - sent, 0);
if (result == -1 && errno == EINTR) continue;
if (result <= 0) return 0;
sent += (size_t)result;
}
return 1;
}
The offset advances by the number of bytes actually written. If send() is interrupted by a signal, the function retries; if it fails or makes no progress, it reports failure. For the small replies this server generates, a partial write may be uncommon, but handling it is part of getting the socket contract right. (The guide also discusses blocking calls and I/O multiplexing; this server stays with blocking calls and serves one client at a time.)
RESP replies are serialized too
RESP is used in both directions. PING returns a simple string, SET returns +OK, and GET returns a bulk string or a null bulk string when the key is absent:
+PONG\r\n
+OK\r\n
$5\r\nearth\r\n
$-1\r\n
The serializers write the marker, byte length where required, payload, and trailing CRLF into an output buffer. Keeping serialization separate from command handling makes the response shape visible: command code chooses a result, serializer code turns it into protocol bytes.
The command buffer and database have different lifetimes
parse_command() allocates a Command. Its argument arrays belong to that command, and the server frees the command after handle_command() returns. That is fine for PING and ECHO, which only need their arguments while the command is being handled. It would be wrong for SET if the database kept pointers directly into command->argv: those pointers would dangle as soon as the command was freed.
Instead, db_set() allocates space for a new key and value, copies the characters into those allocations, and stores the entry in the database. Updating a key uses realloc() for its value. The database therefore owns its copies independently of the request that created them. db_get() returns a pointer to a database-owned value; the server serializes it into the response before processing another command.
The database is a fixed array of up to 100 entry pointers. db_find() scans it linearly, which is simple to follow and entirely adequate at this size. A real Redis-style store needs a hash table and considerably more machinery, but changing the data structure is a separate problem from getting the lifetime rules right.
Where the small implementation draws its lines
This was more like a learning project, not a production Redis replacement, obviously. It handles one client at a time, keeps data only in process memory, and supports a small set of commands. Its fixed limits are four arguments, fewer than 100 bytes per argument, 100 database entries, and a 1024-byte input buffer.
There is also a subtle consequence to storing arguments as C strings. RESP bulk strings are length-delimited and can represent arbitrary bytes, but this implementation later uses strlen(), strcmp(), and strcpy() for keys and values. An embedded NUL byte would therefore be treated as the end of the value. The parser understands a byte count, but the storage and command layer are text-only. A binary-safe version would keep lengths alongside the bytes and avoid C string functions for stored data.
There is no persistence, expiration, authentication, concurrency, or support for Redis’s wider command set. That is a useful boundary to state plainly: compatibility here means a RESP client can use the implemented commands, not that the server implements Redis as a whole.
Client compatibility
The tiny version of Redis works with the real redis-cli for its supported commands. No custom client is needed.
The implementation is small enough that a round trip can be followed from the bytes in the socket buffer, through the command structure, into database-owned memory, and back out as a RESP response. That is the part I wanted to understand by building it: how the familiar SET and GET interface depends on framing, ownership, and careful treatment of the byte stream underneath it.