One JSON HTTP server, eight runtimes
What does the same small JSON API look like across languages, and what actually differs underneath?
Every stack parses HTTP, routes, decodes JSON and writes a response in roughly the same dozen lines. The real difference is the concurrency model: what runs your handler, what a blocking call stalls, and how the process uses more than one core.
THE MENTAL MODEL
Read each server as a scheduler with an HTTP parser attached. Node, Bun and Deno run handlers on one JavaScript thread and park I/O in the kernel, so one CPU-heavy handler stalls everyone and you scale by adding processes. Python's ASGI servers do the same per worker process, with a thread pool for plain def routes. Go starts a goroutine per connection and the runtime multiplexes them over all cores; Rust's Tokio does the same with futures and work stealing, but a blocking call inside async code steals a whole worker thread. Classic Java and .NET take a thread (pool) per request; Java 21 virtual threads and .NET async/await make waiting cheap again. Elixir gives each connection its own BEAM process with preemptive scheduling, so one slow request cannot starve the others. Pick by what your handlers do while waiting, not by hello-world benchmarks.
HOW IT FITS TOGETHER
| Concurrency model | What blocks the server | Using all cores | Typical deploy | |
|---|---|---|---|---|
| Node.js (node:http) | Single JS thread, libuv event loop | Sync fs/crypto, CPU loops, huge JSON.parse | One process per core: replicas or node:cluster | Container replicas behind an LB |
| Bun / Deno | Single JS thread event loop, native HTTP stack | Same as Node: any long synchronous JS | reusePort processes (Linux) or more replicas | Container, or the vendor's edge platform |
| Python FastAPI + uvicorn | asyncio loop per worker; def routes in a thread pool | Blocking calls inside async def; CPU work (GIL) | --workers N processes, or one process per pod | uvicorn/fastapi run in a container |
| Go net/http | Goroutine per connection, M:N on GOMAXPROCS threads | Little: blocking syscalls park the goroutine | Automatic: one process uses every core | Single static binary in a small image |
| Rust axum (Tokio) | Async tasks on a work-stealing thread pool | Blocking or CPU work inside async fn; use spawn_blocking | Automatic: one worker thread per core | Static binary, often distroless |
| Java Spring MVC (Tomcat) | Thread per request from a pool (200 max by default) | Pool exhaustion when every thread waits on slow I/O | Automatic: JVM threads use all cores | Fat JAR or image on the JVM |
| Java 21+ virtual threads | Virtual thread per request on few carrier threads | Pinning (native calls); unbounded downstream load | Automatic | Same JAR with spring.threads.virtual.enabled |
| C# ASP.NET Core (Kestrel) | async/await continuations on the .NET thread pool | Sync-over-async (.Result, .Wait) starving the pool | Automatic | Container or self-contained single file |
| Elixir Plug + Bandit (BEAM) | Lightweight BEAM process per connection, preemptive | Rarely the VM: long NIFs; usually a shared GenServer | Automatic: one scheduler per core; clusters too | Mix release in a container |
- Accept the connectionKernel completes TCP (and the server TLS). Event loops get a readiness event; thread-per-request servers hand the socket to a worker.
- Parse request headMethod, path, headers. Header-read timeouts belong here (slowloris).
- RouteMatch method + path. Unmatched paths become 404; wrong methods 405 in routers that track them.
- Read and decode the bodyStream bytes with a size limit, then parse JSON. Malformed input should be a 400, not a 500.
- Handler waits on I/ODatabase, cache, other services. This is where the concurrency model matters: what else runs while you wait?
- Serialize and writeEncode JSON, set Content-Type, write status and body. Slow clients create backpressure here.
- Keep-alive or closeThe connection returns to idle for the next request, until the idle timeout.
- Graceful shutdownOn SIGTERM: stop accepting, finish in-flight requests, close idle connections, exit before the hard kill.
KEY TERMS
- Event loop servers
- One thread runs all handlers; I/O waits are registered with the kernel (epoll, kqueue, IOCP) and resumed as callbacks or awaits. Cheap per connection, but any synchronous CPU work is a global pause. Node, Bun, Deno and each uvicorn worker work this way.
- Thread per request
- Each in-flight request owns an OS thread from a bounded pool (Tomcat defaults to 200). Code is plain blocking code, but when every thread waits on a slow dependency new requests queue even though CPUs are idle.
- Green threads: goroutines, virtual threads, BEAM processes
- The runtime schedules many cheap user-space threads over a few OS threads and parks them on I/O, so blocking-style code scales to hundreds of thousands of concurrent requests. BEAM adds preemption by reduction count, so even a CPU-bound process cannot hog a scheduler.
- Async tasks on a work-stealing runtime
- Tokio and .NET turn async functions into state machines scheduled on a thread pool; idle workers steal queued tasks from busy ones. Awaiting is cheap, but a blocking call inside async code holds a worker thread, so offload it (spawn_blocking, Task.Run for CPU work).
- Scaling across cores
- Single-threaded runtimes scale out with processes (replicas, cluster, SO_REUSEPORT, uvicorn workers); Go, Rust, JVM, .NET and BEAM use every core in one process. In Kubernetes the usual answer for all of them is more pods, with CPU limits that match what the runtime thinks it has.
- Graceful shutdown
- Orchestrators send SIGTERM, wait a grace period (30 s by default in Kubernetes), then SIGKILL. A correct server stops accepting, drains in-flight requests and closes pools inside that window. Spring Boot, ASP.NET Core and uvicorn do it by default; Node, Go and axum need a few lines.
IN YOUR STACK
JavaScript · Node.js 24 node:http Server
import { createServer } from "node:http";
async function readJson(req, limit = 1_000_000) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw Object.assign(new Error("body too large"), { status: 413 });
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8")); // SyntaxError -> 400 below
}
const send = (res, status, body) =>
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
const server = createServer(async (req, res) => {
try {
if (req.method === "GET" && req.url === "/health") return send(res, 200, { ok: true });
if (req.method === "POST" && req.url === "/echo") return send(res, 200, await readJson(req));
send(res, 404, { error: "not found" });
} catch (err) {
send(res, err.status ?? 400, { error: err.message });
}
}).listen(3000);
process.once("SIGTERM", () => server.close()); // stop accepting, drain, then exit- Model: one JavaScript thread. While readJson awaits chunks other requests run; a synchronous 50 MB JSON.parse would stall them all.
- node:http gives you no router, body parser or size limit; that is what Express, Fastify and Hono add.
- To use more cores run several processes (replicas or node:cluster). worker_threads are for CPU work, not for serving HTTP.
TypeScript · Bun.serve (Bun 1.2.3+) Server
const server = Bun.serve({
port: 3000,
routes: {
"/health": { GET: () => Response.json({ ok: true }) },
"/echo": {
POST: async (req) => {
try {
return Response.json(await req.json());
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
},
},
},
// Anything the routes table does not match.
fetch: () => Response.json({ error: "not found" }, { status: 404 }),
});
process.on("SIGTERM", async () => {
await server.stop(); // stop accepting; in-flight requests finish
process.exit(0);
});
console.log(`listening on ${server.url}`);- Same single-threaded JavaScript model as Node, with the HTTP parser and routing implemented natively and Web Request/Response objects.
- Connections idle for 10 s are closed by default (idleTimeout, max 255 s), which also cuts long-lived streams that stop sending.
- For more cores, start several processes with reusePort: true; Linux only, macOS and Windows ignore it.
Python · FastAPI + uvicorn Server
import time
from typing import Any
from fastapi import FastAPI
app = FastAPI() # unknown paths -> 404 {"detail": "Not Found"} automatically
@app.get("/health")
async def health() -> dict[str, bool]:
return {"ok": True}
@app.post("/echo")
async def echo(payload: dict[str, Any]) -> dict[str, Any]:
return payload # body parsed and validated; malformed JSON -> 422
@app.get("/slow")
def slow() -> dict[str, str]:
time.sleep(2) # plain def runs in a thread pool, so the event loop keeps serving
return {"done": "yes"}
# fastapi run main.py --workers 4 (or: uvicorn main:app --workers 4)- Model: one asyncio event loop per worker process. time.sleep or a sync DB driver inside async def blocks every request on that worker.
- The GIL means CPU-bound handlers do not parallelize across threads; use multiple worker processes, or one process per container and scale pods.
- On SIGTERM uvicorn stops accepting, waits for in-flight requests and runs lifespan shutdown handlers; --timeout-graceful-shutdown bounds the wait.
Go · net/http (Go 1.22+ patterns) Server
// package main; imports: encoding/json, log, net/http, time
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
mux := http.NewServeMux() // unmatched path -> 404, wrong method -> 405
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
mux.HandleFunc("POST /echo", func(w http.ResponseWriter, r *http.Request) {
var body any
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, body)
})
srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(srv.ListenAndServe())
}- Model: every connection gets its own goroutine, scheduled across all cores. Blocking calls are fine; the runtime parks the goroutine, not the thread.
- Method patterns ("GET /health") and {name} wildcards with r.PathValue arrived in Go 1.22; GET also matches HEAD.
- For graceful shutdown, run ListenAndServe in a goroutine, wait on signal.NotifyContext, then call srv.Shutdown(ctx) and wait for it to return before main exits.
Rust · axum 0.8 + Tokio Server
use axum::{http::StatusCode, routing::{get, post}, Json, Router};
use serde_json::{json, Value};
use tokio::signal::unix::{signal, SignalKind};
async fn health() -> Json<Value> { Json(json!({ "ok": true })) }
// Json<Value>: bad JSON -> 400, wrong Content-Type -> 415, before this runs.
async fn echo(Json(body): Json<Value>) -> Json<Value> { Json(body) }
async fn not_found() -> (StatusCode, Json<Value>) {
(StatusCode::NOT_FOUND, Json(json!({ "error": "not found" })))
}
async fn shutdown_signal() {
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
tokio::select! { _ = term.recv() => {}, _ = tokio::signal::ctrl_c() => {} }
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", get(health)).route("/echo", post(echo)).fallback(not_found);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await.unwrap();
}- Model: async tasks on Tokio's multi-thread runtime, one worker per core with work stealing. A blocking call in a handler holds a worker; use tokio::task::spawn_blocking.
- Extractor bodies (Json, String, Bytes) are capped at 2 MB by default; change it with DefaultBodyLimit.
- Needs tokio features macros, rt-multi-thread, net and signal, plus serde_json. The unix signal module makes this example Unix-only.
Java · Spring Boot 4 (Spring MVC) Server
// dependency: spring-boot-starter-webmvc (named spring-boot-starter-web before Boot 4)
// application.properties: spring.threads.virtual.enabled=true (Java 21+)
package demo;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
@SpringBootApplication
@RestController
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
@GetMapping("/health")
Map<String, Boolean> health() { return Map.of("ok", true); }
// Malformed JSON -> 400; unknown paths -> 404 JSON error body from Boot.
@PostMapping("/echo")
Map<String, Object> echo(@RequestBody Map<String, Object> body) { return body; }
}- Without the property, Tomcat runs each request on a pooled platform thread (200 max by default); slow downstream calls exhaust the pool.
- With virtual threads each request gets a cheap thread that unmounts while blocked. Since JDK 24 synchronized no longer pins; native calls still do. Limit downstream concurrency with a semaphore or connection pool, not thread counts.
- Graceful shutdown is on by default; spring.lifecycle.timeout-per-shutdown-phase sets how long in-flight requests get.
C# · ASP.NET Core 10 minimal APIs Server
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/health", () => TypedResults.Ok(new { ok = true }));
// Body binding uses System.Text.Json: malformed JSON -> 400,
// non-JSON Content-Type -> 415. Unmatched routes -> 404.
app.MapPost("/echo", ([FromBody] JsonElement body) => TypedResults.Ok(body));
app.MapGet("/slow", async (CancellationToken ct) =>
{
await Task.Delay(2000, ct); // frees the thread while waiting; never .Result or .Wait()
return TypedResults.Ok(new { done = true });
});
app.Run(); // SIGTERM: stop accepting, drain in-flight requests, then exit- Model: Kestrel plus async/await on the shared .NET thread pool. Awaiting releases the thread; sync-over-async blocks it and can starve the pool under load.
- The CancellationToken parameter binds to RequestAborted, so work stops when the client disconnects.
- Shutdown waits up to HostOptions.ShutdownTimeout (30 s by default) for in-flight requests.
Elixir · Plug.Router + Bandit Server
# deps: {:bandit, "~> 1.0"} (pulls in Plug); Elixir 1.18+ for the JSON module
defmodule Echo.Router do
use Plug.Router
plug :match
plug Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: JSON
plug :dispatch
get "/health" do
send_json(conn, 200, %{ok: true})
end
# Malformed JSON raises Plug.Parsers.ParseError (plug_status 400).
post "/echo" do
send_json(conn, 200, conn.body_params)
end
match _ do
send_json(conn, 404, %{error: "not found"})
end
defp send_json(conn, status, data),
do: conn |> put_resp_content_type("application/json") |> send_resp(status, JSON.encode!(data))
end
# application.ex: children = [{Bandit, plug: Echo.Router, port: 4000}]- Model: each connection is its own BEAM process, scheduled preemptively across one scheduler per core. A crash or slow handler affects only that process.
- The usual bottleneck is a single GenServer that every request calls, which serializes them; the VM itself rarely blocks unless a long NIF runs.
- Phoenix uses the same Plug and Bandit stack underneath; shutdown is handled by the OTP supervision tree when the release receives SIGTERM.
WHEN TO REACH FOR WHAT
Node.js / Bun (TypeScript)
Use when The team is already TypeScript end to end, handlers mostly wait on I/O, and you want to share types and validation with the frontend.
Avoid when Requests do heavy CPU work (image processing, large report generation) that you cannot move off the main thread.
Go net/http
Use when You want a small static binary, predictable latency, blocking-style code that uses every core, and a strong standard library for HTTP.
Avoid when You need a rich ORM or framework ecosystem, or the team expects exceptions and generics-heavy abstractions.
Python FastAPI
Use when The service sits next to data or ML code, or the team is Python-first and the workload is I/O-bound.
Avoid when Handlers are CPU-heavy or latency budgets are tight; the GIL and per-process memory make scaling more expensive.
Java / Kotlin Spring Boot
Use when Large teams, long-lived business systems and deep integrations (security, data, messaging); virtual threads keep blocking code scalable.
Avoid when Fast cold starts and tiny memory footprints matter (consider native images) or the service is a thin proxy.
C# ASP.NET Core
Use when You want high throughput with a mature, batteries-included framework and strong tooling, especially in a .NET or Azure shop.
Avoid when The team has no .NET experience and the service is small enough that the ecosystem advantage does not pay off.
Rust axum
Use when Tail latency, memory footprint or safety-critical parsing matter enough to pay for slower development and compile times.
Avoid when The service is CRUD over a database where developer speed dominates and the bottleneck is the database.
Elixir Phoenix / Plug
Use when Many long-lived connections (WebSockets, presence, chat) and fault isolation per connection are core to the product.
Avoid when Hiring pool and libraries for your domain are thin, or work is CPU-bound number crunching.
WHERE IT BITES
- Doing blocking work inside an event loop or async runtime: a sync DB driver in async def, readFileSync in Node, std::fs or a mutex held across awaits in Tokio, .Result in C#. Each stalls far more than the one request.
- Reading request bodies without a size limit. node:http and many minimal servers buffer whatever the client sends unless you cap it (MaxBytesReader, DefaultBodyLimit, a limit in your read loop).
- Missing graceful shutdown: if the server exits as soon as SIGTERM arrives, every rolling deploy drops in-flight requests and you see 502s at the load balancer.
- Running N workers or threads that each open a full database pool. Total connections are workers × pool size × replicas, which exhausts the database long before the CPU.
- Assuming virtual threads or goroutines remove backpressure. Cheap concurrency lets 10,000 requests hit a dependency at once; bound it with semaphores, pools and timeouts.
- Misreading container CPU limits: some runtimes size their thread pools or workers from the host's core count rather than the cgroup quota. Check what yours detects and set it explicitly (GOMAXPROCS, workers, pool sizes) when they disagree.
CLOSE THE AI. EXPLAIN THIS.
Your /echo handler starts calling a slow downstream service that takes 2 s. For Node, FastAPI (async def), Spring on platform threads, Spring on virtual threads and Go, what happens to a fast /health request arriving at the same time, and why?WHEN IT BREAKS IN PRODUCTION
SOURCES
- Go: Routing enhancements for Go 1.22 (opens in new tab)Checked
- FastAPI: Concurrency and async / await (opens in new tab)Checked
- Tokio: runtime module (opens in new tab)Checked
- Oracle: Virtual Threads (Java SE 25) (opens in new tab)Checked
- Bun: HTTP server (opens in new tab)Checked
- Spring Boot: Graceful shutdown (opens in new tab)Checked
Explainer reviewed