Build a TCP Key-Value Store Server and Client
A line-oriented TCP server that stores keys in memory with optional persistence, supports GET, SET, DEL, and KEYS commands, and ships a client that speaks the same protocol.
Project goal
Build a TCP server that accepts line-based commands over a socket, implements GET, SET, DEL, and KEYS against an in-memory map, snapshots to disk, and provide a client for the same protocol.
Final features
- Accept concurrent TCP connections and read one command per line.
- Implement GET, SET, DEL, KEYS, and QUIT with clear replies.
- Persist the map to a snapshot file and reload it on start.
- Return protocol errors for malformed commands without dropping the connection.
- Provide a client that sends a command and prints the reply.
Architecture
A protocol module parses a line into a command and renders a reply; a store module owns the map and optional snapshot; a connection handler loops over lines using a buffered reader for partial packets; and the client reuses the protocol module to stay in sync with the server.
Prerequisites
- Node.js 20 LTS or later
- Understanding of TCP sockets and streaming data
- Basic knowledge of line-delimited protocols
Dependencies
- node >=20 — node:net for the TCP server and client
- vitest ^2.1.0 — Protocol and store tests, plus a socket round-trip
Directory structure
kv-store/
├── src/
│ ├── protocol.js
│ ├── store.js
│ ├── server.js
│ └── client.js
├── test/
│ ├── protocol.test.js
│ └── socket.test.js
└── package.json
Phases
1. Store with snapshots
Implement an in-memory map with optional snapshot load and save and a keys listing.
Files: src/store.js
import { readFileSync, writeFileSync } from 'node:fs';
export function createStore(snapshotPath) {
let data = new Map();
if (snapshotPath) {
try { data = new Map(Object.entries(JSON.parse(readFileSync(snapshotPath, 'utf8')))); }
catch { data = new Map(); }
}
return {
get: (k) => data.get(k),
set: (k, v) => { data.set(k, v); },
del: (k) => data.delete(k),
keys: () => [...data.keys()],
snapshot() {
if (snapshotPath) writeFileSync(snapshotPath, JSON.stringify(Object.fromEntries(data)));
},
};
}Expected behavior: Setting then getting returns the value, deleting reports true once and false after, and a snapshot reloads the same map.
2. Protocol parsing
Split a command line into a verb and arguments and render a reply string for every case.
Files: src/protocol.js
function splitArgs(line) {
return line.trim().split(/\s+/);
}
export function execute(store, line) {
const [verb, key, ...rest] = splitArgs(line);
switch ((verb ?? '').toUpperCase()) {
case 'GET': {
const value = store.get(key);
return value === undefined ? '(nil)' : value;
}
case 'SET':
store.set(key, rest.join(' '));
return 'OK';
case 'DEL':
return store.del(key) ? '1' : '0';
case 'KEYS':
return store.keys().join(' ');
case 'QUIT':
return 'BYE';
default:
return 'ERR unknown command';
}
}Expected behavior: A SET stores a value with spaces in it, a GET of a missing key returns the nil marker, and an unknown verb returns ERR without throwing.
3. Line-buffered connection handling
Read a socket with a buffer that survives partial packets and process one command per newline.
Files: src/connection.js
import { execute } from './protocol.js';
export function handleConnection(socket, store) {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
const reply = execute(store, line);
socket.write(reply + '\n');
if (reply === 'BYE') socket.end();
}
});
socket.on('error', () => socket.destroy());
}Expected behavior: A command split across two data events is processed once, and two commands in one chunk produce two replies in order.
4. Snapshot on write
Persist the store after mutating commands and on shutdown so a clean restart resumes state.
Files: src/persistence.js
export function withSnapshot(store, { everyWrites = 10 } = {}) {
let writes = 0;
return {
...store,
set(key, value) { store.set(key, value); if (++writes % everyWrites === 0) store.snapshot(); },
del(key) { const ok = store.del(key); store.snapshot(); return ok; },
};
}Expected behavior: A periodic snapshot runs after the configured number of writes, and a delete snapshots immediately so it survives a crash.
5. Server and client
Start the TCP server on a configurable port and provide a client that sends one command and prints the reply.
Files: src/server.js, src/client.js
import { createServer } from 'node:net';
import { createStore } from './store.js';
import { handleConnection } from './connection.js';
const store = createStore(process.env.SNAPSHOT ?? './kv.snapshot');
const server = createServer((socket) => handleConnection(socket, store));
server.listen(process.env.PORT ?? 6379, () => console.log('kv listening on ' + (process.env.PORT ?? 6379)));
export { server };Expected behavior: The server logs a listening line on start, and a client sending SET then GET over a socket receives OK then the stored value.
6. Socket round-trip tests
Start the server on an ephemeral port, connect a client, and assert the reply stream for a command sequence.
Files: test/socket.test.js
import { createServer } from 'node:net';
import { connect } from 'node:net';
import { createStore } from '../src/store.js';
import { handleConnection } from '../src/connection.js';
test('set then get over a real socket', async () => {
const store = createStore();
const server = createServer((s) => handleConnection(s, store));
await new Promise((r) => server.listen(0, r));
const client = connect(server.address().port);
const replies = [];
client.on('data', (d) => replies.push(...d.toString().trim().split('\n')));
client.write('SET greeting hello\n');
client.write('GET greeting\n');
await new Promise((r) => setTimeout(r, 50));
expect(replies).toEqual(['OK', 'hello']);
client.end();
server.close();
});Expected behavior: The socket test passes with the server on an OS-assigned port, and the two replies arrive in the order the commands were sent.
Testing
Run npx vitest. Protocol tests assert every command and the error reply, including a SET of an empty value. Store tests cover overwrite, delete, and snapshot round-trip. A socket test starts the server on an ephemeral port, sends a SET then a GET through a real client, and asserts the reply.
Troubleshooting
A command sent in two packets parses as two broken commands
Buffer incoming data and only process complete lines, keeping the trailing partial bytes for the next event; assuming one data event equals one command is the classic framing bug.
The server hangs on shutdown
Call socket.end on QUIT and close the listener on shutdown; leaving sockets open keeps the event loop alive and the process never exits.
A value containing spaces is truncated
Split the verb and key from the line first, then take the remainder as the value; re-splitting the whole line on whitespace loses the spaces inside the value.
Deployment
Bind the server behind a private network and expose only to trusted clients, since the protocol has no authentication. The snapshot file on a mounted volume gives restart durability; a production version would add auth and a write-ahead log.
Known limitations
- Data lives in memory and is snapshotted, not written per operation, so a hard crash can lose recent writes.
- There is no authentication or access control; anyone who can reach the port can read and write every key.
Project overview
A key-value server on a raw TCP socket is the shortest path to understanding network programming: framing, partial reads, concurrent connections, and a protocol that both ends share.
The store keeps data in memory and snapshots to disk, so a restart resumes state. Every command is parsed and rendered by one shared protocol module, which is what keeps client and server honest.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The protocol is a pure function over a line, which makes both ends testable without opening a socket:
export function execute(store, line) {
const [verb, key, ...rest] = splitArgs(line);
switch ((verb ?? '').toUpperCase()) {
case 'GET': {
const value = store.get(key);
return value === undefined ? '(nil)' : value;
}
case 'SET':
store.set(key, rest.join(' '));
return 'OK';
case 'DEL':
return store.del(key) ? '1' : '0';
case 'KEYS':
return store.keys().join(' ');
case 'QUIT':
return 'BYE';
default:
return 'ERR unknown command';
}
}
Splitting parsing from transport means the same function answers a socket command in production and a test assertion in the suite.
Setup
No dependencies beyond the test runner; node:net provides both the listening socket and the connecting client.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | node:net for the TCP server and client |
| vitest | ^2.1.0 | Protocol and store tests, plus a socket round-trip |
Create the project and install:
mkdir kv-store && cd kv-store
npm init -y
npm install -D vitest@^2.1.0
Configure the server and client entries plus the test runner:
{
"name": "kv-store",
"type": "module",
"scripts": {
"server": "node src/server.js",
"client": "node src/client.js",
"test": "vitest run"
}
}
Tests bind the server to port 0 so the operating system picks a free port and parallel test runs never collide.
Phase 1: Store with snapshots
Implement an in-memory map with optional snapshot load and save and a keys listing. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
import { readFileSync, writeFileSync } from 'node:fs';
export function createStore(snapshotPath) {
let data = new Map();
if (snapshotPath) {
try { data = new Map(Object.entries(JSON.parse(readFileSync(snapshotPath, 'utf8')))); }
catch { data = new Map(); }
}
return {
get: (k) => data.get(k),
set: (k, v) => { data.set(k, v); },
del: (k) => data.delete(k),
keys: () => [...data.keys()],
snapshot() {
if (snapshotPath) writeFileSync(snapshotPath, JSON.stringify(Object.fromEntries(data)));
},
};
}
Acceptance for this phase: Setting then getting returns the value, deleting reports true once and false after, and a snapshot reloads the same map.
Phase 2: Protocol parsing
Split a command line into a verb and arguments and render a reply string for every case. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
function splitArgs(line) {
return line.trim().split(/\s+/);
}
export function execute(store, line) {
const [verb, key, ...rest] = splitArgs(line);
switch ((verb ?? '').toUpperCase()) {
case 'GET': {
const value = store.get(key);
return value === undefined ? '(nil)' : value;
}
case 'SET':
store.set(key, rest.join(' '));
return 'OK';
case 'DEL':
return store.del(key) ? '1' : '0';
case 'KEYS':
return store.keys().join(' ');
case 'QUIT':
return 'BYE';
default:
return 'ERR unknown command';
}
}
Acceptance for this phase: A SET stores a value with spaces in it, a GET of a missing key returns the nil marker, and an unknown verb returns ERR without throwing.
Phase 3: Line-buffered connection handling
Read a socket with a buffer that survives partial packets and process one command per newline. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
import { execute } from './protocol.js';
export function handleConnection(socket, store) {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
const reply = execute(store, line);
socket.write(reply + '\n');
if (reply === 'BYE') socket.end();
}
});
socket.on('error', () => socket.destroy());
}
Acceptance for this phase: A command split across two data events is processed once, and two commands in one chunk produce two replies in order.
Phase 4: Snapshot on write
Persist the store after mutating commands and on shutdown so a clean restart resumes state. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
export function withSnapshot(store, { everyWrites = 10 } = {}) {
let writes = 0;
return {
...store,
set(key, value) { store.set(key, value); if (++writes % everyWrites === 0) store.snapshot(); },
del(key) { const ok = store.del(key); store.snapshot(); return ok; },
};
}
Acceptance for this phase: A periodic snapshot runs after the configured number of writes, and a delete snapshots immediately so it survives a crash.
Phase 5: Server and client
Start the TCP server on a configurable port and provide a client that sends one command and prints the reply. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
import { createServer } from 'node:net';
import { createStore } from './store.js';
import { handleConnection } from './connection.js';
const store = createStore(process.env.SNAPSHOT ?? './kv.snapshot');
const server = createServer((socket) => handleConnection(socket, store));
server.listen(process.env.PORT ?? 6379, () => console.log('kv listening on ' + (process.env.PORT ?? 6379)));
export { server };
Acceptance for this phase: The server logs a listening line on start, and a client sending SET then GET over a socket receives OK then the stored value.
Phase 6: Socket round-trip tests
Start the server on an ephemeral port, connect a client, and assert the reply stream for a command sequence. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { createServer } from 'node:net';
import { connect } from 'node:net';
import { createStore } from '../src/store.js';
import { handleConnection } from '../src/connection.js';
test('set then get over a real socket', async () => {
const store = createStore();
const server = createServer((s) => handleConnection(s, store));
await new Promise((r) => server.listen(0, r));
const client = connect(server.address().port);
const replies = [];
client.on('data', (d) => replies.push(...d.toString().trim().split('\n')));
client.write('SET greeting hello\n');
client.write('GET greeting\n');
await new Promise((r) => setTimeout(r, 50));
expect(replies).toEqual(['OK', 'hello']);
client.end();
server.close();
});
Acceptance for this phase: The socket test passes with the server on an OS-assigned port, and the two replies arrive in the order the commands were sent.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Protocol tests assert every command and the error reply, including a SET of an empty value. Store tests cover overwrite, delete, and snapshot round-trip. A socket test starts the server on an ephemeral port, sends a SET then a GET through a real client, and asserts the reply.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The framing test delivers a command in two chunks and asserts the server still parses it, which is the partial-read bug that naive socket servers hit first.
Extending the project
Expiry, atomic counters, and pub-sub are additional verbs over the same store. A snapshot interval or write-ahead log upgrades durability, and a binary framing is a drop-in replacement for the line reader because the command handling sits behind the protocol module.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.