The simplest possible HTTP server, across two categories of languages.
Each implementation listens on port 8080 and replies with HTTP 200 to any request.
To see what each language looks like at the floor: before frameworks, before abstractions, before opinions. Run them, read them, compare them.
These languages ship an HTTP server in their standard SDK. No external packages required. They model how most application servers are built.
| Language | Command | Prerequisite |
|---|---|---|
| Go | make go |
Go 1.26+ |
| Java | make java |
JDK 25+ |
| Node.js | make nodejs |
Node.js 24 LTS+ |
| .NET | make dotnet |
.NET 10+ |
These languages lack a stdlib HTTP server. They use foundational HTTP libraries — the same layer that nginx, envoy, and HAProxy are built on. External dependencies are allowed but each is justified in ADR-0006.
| Language | Command | Prerequisite |
|---|---|---|
| C | make c |
C23 compiler + libmicrohttpd-dev |
| C++ | make cpp |
C++23 compiler + libboost-all-dev |
| Rust | make rust |
Rust 1.x stable |
| Zig | make zig |
Zig 0.14+ |
| D | make d |
DMD 2.110+ or LDC |
Test any server with:
curl -i http://localhost:8080/Expected response: HTTP/1.1 200 OK
Uses net/http from the Go standard library.
An empty handler causes net/http to flush a 200 automatically.
Run: make go or cd go && go run main.go
Uses com.sun.net.httpserver.HttpServer — ships in every JDK.
Uses JEP 512 Compact Source Files (finalized in Java 25): no class declaration,
no javac step. Uses virtual threads (Executors.newVirtualThreadPerTaskExecutor())
for modern Java concurrency.
Run: make java or cd java && java Server.java
Uses the built-in node:http module with ES
module syntax. node: prefix is canonical in Node.js 24.
Run: make nodejs or cd nodejs && node server.js
Uses ASP.NET Core Minimal APIs
via Microsoft.NET.Sdk.Web (ships with the .NET 10 SDK — no dotnet add package).
Kestrel serves HTTP directly.
Run: make dotnet or cd dotnet && dotnet run
Uses libmicrohttpd — a GNU embedded HTTP server library, pure C, LGPLv2.1. Present in every major Linux distro. The callback-based API requires no manual HTTP framing.
Run: make c (install: sudo apt-get install libmicrohttpd-dev)
Concurrency: OS threads (internal polling thread)
I/O: Event polling (epoll / kqueue)
Uses Boost.Beast over
Boost.Asio.
Asio is the proposed basis for the C++ networking stdlib; Beast adds the HTTP
layer on top. Each connection is a C++20 coroutine (asio::awaitable<void>
launched with asio::co_spawn); the loop co_awaits async_read / async_write,
suspending the coroutine — not the OS thread — until the I/O completes.
Run: make cpp (install: sudo apt-get install libboost-all-dev)
Concurrency: C++20 stackless coroutine per connection on a single-threaded io_context (co_await at each async_read / async_write)
I/O: Event loop (Asio reactor: epoll / kqueue / io_uring)
Uses tokio (async runtime) + hyper (HTTP library). Hyper is the foundation of pingora, linkerd2-proxy, and most production Rust HTTP infrastructure. No application framework layer.
Run: make rust or cd rust && cargo run --release
Concurrency: M:N async tasks on Tokio's multi-thread runtime
I/O: Event loop (mio: epoll / kqueue / IOCP)
Uses std.http.Server
from the Zig standard library — Zig is the only Category-2 language with a
built-in HTTP server. Thread-per-connection via std.Thread.spawn.
Run: make zig or cd zig && zig build run
Concurrency: Thread-per-connection (std.Thread)
I/O: Blocking I/O
Uses vibe-http — the HTTP-only sub-package
of the vibe.d project. vibe-http provides HTTP/1 + HTTP/2 built on vibe-core's
fiber scheduler; it is the foundational layer that the vibe.d web framework
builds on top of.
Run: make d or cd d && dub run
Concurrency: Fibers (vibe-core's M:N fiber scheduler over OS threads)
I/O: Event loop (eventcore: epoll / kqueue / IOCP)
| Language | Version | LOC | Run command | HTTP primitive | I/O event notification | Event dispatch |
|---|---|---|---|---|---|---|
| Go | 1.26 | 5 | go run main.go |
net/http |
epoll | Goroutine per connection |
| Java | 25 | 8 | java Server.java |
com.sun.net.httpserver |
epoll | Virtual thread per request |
| Node.js | 24 | 2 | node server.js |
node:http |
epoll | Callback on event loop |
| Node.js (Bun) | latest | 2 | bun server.js |
node:http |
epoll | Callback on event loop |
| Node.js (Bun Canary) | canary | 2 | bun server.js |
node:http |
epoll | Callback on event loop |
| .NET | 10 | 3 | dotnet run |
Kestrel (Sdk.Web) |
epoll | Thread-pool task |
| Language | Version | LOC | Run command | HTTP library | External dep | I/O event notification | Event dispatch |
|---|---|---|---|---|---|---|---|
| C | C23 | ~25 | cc -std=c23 server.c -lmicrohttpd -o server && ./server |
libmicrohttpd | libmicrohttpd-dev |
epoll | OS thread per connection |
| C++ | C++23 | ~25 | c++ -std=c++23 server.cpp -lpthread -o server && ./server |
Boost.Beast | libboost-all-dev |
epoll | C++20 coroutine per connection |
| Rust | 1.x | ~20 | cargo run --release |
hyper | tokio, hyper, hyper-util, http-body-util | epoll | Async task (Tokio) |
| Zig | 0.14 | ~25 | zig build run |
std.http.Server |
none | none (blocking) | New thread per connection |
| D | 2.110 | ~10 | dub run |
vibe-http | vibe-http | epoll | Fiber per connection |
Every concurrency model in the table above ultimately runs on OS threads —
the only unit of execution the operating system kernel actually schedules onto
CPU cores. An OS thread (a POSIX pthread / Linux task, created via clone(2))
is a kernel-managed sequence of execution with its own stack and register state.
The kernel's scheduler decides which thread runs on which core and preempts it
(forcibly suspends it) when its time slice expires or it blocks on a syscall.
OS threads are powerful but heavy:
- Memory: each one reserves a stack (typically 1–8 MB of virtual address space by default), so tens of thousands of them cost real memory.
- Creation/teardown: a syscall into the kernel — milliseconds-scale, not free.
- Context switching: swapping one thread for another means a kernel trap, saving/restoring registers, and often a TLB/cache disruption — on the order of microseconds, which adds up under high concurrency (the "C10k" problem).
Because of this cost, language runtimes invented lighter-weight abstractions (goroutines, virtual threads, async tasks, fibers, coroutines). None of them escape OS threads — they all multiplex many cheap user-space units onto a small pool of OS threads (usually one per core). The runtime, not the kernel, decides which user-space unit runs on a thread next. The difference between them is mostly who yields control and when, and whether the runtime can move a unit between threads.
All of the following are "run many concurrent tasks on few OS threads" schemes. They differ along three axes: scheduling (cooperative = a task only yields at explicit suspension points; preemptive = the runtime can interrupt it), stack model (does each task own a growable stack, or is its state compiled into a state machine?), and how blocking I/O is handled.
| Primitive | Language / Runtime | Scheduled by | Scheduling | Stack model | Yields on | Can migrate across OS threads? |
|---|---|---|---|---|---|---|
| Goroutine | Go runtime | Go scheduler (M:N) | Preemptive (async, since Go 1.14) | Small growable stack (starts ~2 KB) | Channel ops, blocking calls, function preemption | Yes — work-stealing across GOMAXPROCS threads |
| Virtual thread | JVM (Project Loom) | JVM scheduler (M:N) | Cooperative (yields on blocking I/O) | Heap-stored continuation; unmounts from carrier | Blocking I/O, synchronized/locks, sleeps |
Yes — mounts/unmounts on a pool of carrier threads |
| Async task | Rust + Tokio | Tokio executor (M:N) | Cooperative | No stack — compiled to a Future state machine |
.await points only |
Yes (with Send) — work-stealing scheduler |
| C++20 coroutine | C++ + Boost.Asio | Asio io_context / executor |
Cooperative | Compiler-generated frame (co_await state machine) |
co_await points only |
Depends on executor — single-threaded here, pinned to one thread |
| Coroutine | Kotlin (kotlinx.coroutines) | Dispatcher (M:N) | Cooperative | Compiler-generated continuation (suspend functions) | suspend call points only |
Yes — depends on dispatcher (e.g. Dispatchers.Default) |
| .NET Task | .NET CLR | Thread-pool task scheduler | Cooperative | async/await state machine on heap |
await points only |
Yes — runs on thread-pool worker threads |
| Swift Task | Swift Concurrency | Cooperative thread pool (M:N) | Cooperative | async/await continuation (state machine) |
await (and actor) suspension points |
Yes (Sendable) — unless pinned to an actor/@MainActor |
| Fiber | D (vibe-core) / others | Userspace fiber scheduler | Cooperative | Own dedicated stack (full context switch) | Explicit yields / blocking wrapped calls | Typically pinned to one thread (implementation-dependent) |
Promise / async task |
JavaScript (Node.js, Bun) | Single event loop (1:N) | Cooperative | async/await microtask (state machine) |
await points / promise resolution |
No — one event-loop thread, no parallelism |
Mapping those units onto the two axes — stackful vs. stackless (how a task stores its suspended state) and cooperative vs. preemptive (who decides when it yields):
quadrantChart
title Lightweight concurrency units
x-axis "Stackless (state machine)" --> "Stackful (own call stack)"
y-axis "Cooperative (yields itself)" --> "Preemptive (runtime interrupts)"
quadrant-1 "Stackful + Preemptive"
quadrant-2 "Stackless + Preemptive"
quadrant-3 "Stackless + Cooperative"
quadrant-4 "Stackful + Cooperative"
"Goroutine": [0.78, 0.82]
"Virtual thread": [0.70, 0.28]
"Fiber": [0.85, 0.16]
"Rust async task": [0.20, 0.34]
"Kotlin coroutine": [0.32, 0.20]
".NET Task": [0.18, 0.12]
"Swift Task": [0.30, 0.40]
"C++20 coroutine": [0.42, 0.30]
"JS Promise": [0.14, 0.26]
Note the top-left quadrant is empty: a stackless unit is just a heap state machine with no saved registers/stack, so there's nothing for a preemptive scheduler to safely interrupt mid-step. Goroutines sit alone on the preemptive side — Go's runtime can asynchronously interrupt a running goroutine, while every other unit only yields at explicit suspension points.
The key split is stackful vs. stackless:
- Stackful (goroutines, fibers, virtual threads): each task has a real call stack, so it can suspend from anywhere, deep inside nested function calls, with no special syntax. Costs more memory per task (the stack), but ordinary blocking-looking code "just works."
- Stackless (Rust async tasks, Kotlin coroutines, .NET Tasks, Swift Tasks,
C++20 coroutines, JS promises): the compiler transforms the task into a state
machine, so it can only suspend at explicit
.await/suspend/co_await/awaitpoints. Extremely cheap (a task is just a struct on the heap), but it requires the "function coloring" ofasync/awaitto propagate through the call chain.
Cooperative vs. preemptive matters for fairness: a cooperative task that
never hits a yield point (e.g. a tight CPU loop with no .await) can starve
others sharing its OS thread. Go is unusual here — its scheduler can preempt a
goroutine asynchronously, so a runaway loop won't block the whole runtime.
Parallelism vs. concurrency is a separate axis: every unit above except the
JavaScript event loop multiplexes onto multiple OS threads (M:N), so tasks run
in parallel across cores. JavaScript promises are concurrent but not
parallel — they all share one event-loop thread, so a CPU-bound task blocks
every other task until it yields. (Node.js offers real parallelism only via
separate worker_threads or processes, not via promises.)
Python's http.server, Ruby's WEBrick, and PHP's -S dev server all explicitly
state they are not for production use. The ecosystem convention for each is to run
application code behind a separate production server (Gunicorn, Puma, PHP-FPM)
proxied by Nginx. These languages don't "serve HTTP themselves" in the same way
Go, Java, Node.js, and .NET do. See ADR-0002.
All structural decisions are documented in docs/adr/:
| ADR | Decision |
|---|---|
| 0001 | Use ADRs to document decisions |
| 0002 | Language selection rationale |
| 0003 | Standard SDK only — no external packages (Category 1) |
| 0004 | Standardize on port 8080 |
| 0005 | One directory per language |
| 0006 | Load Balancer / Reverse Proxy category — justified external deps |
See CONTRIBUTING.md and TASKS.md for open work items.
MIT