← Back to the library
BackendHow it works · Applied · 15 min

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 models behind the same endpoint"Blocks the server" means the mistake that stalls unrelated requests. Scaling notes assume a stateless service behind a load balancer.
Concurrency modelWhat blocks the serverUsing all coresTypical deploy
Node.js (node:http)Single JS thread, libuv event loopSync fs/crypto, CPU loops, huge JSON.parseOne process per core: replicas or node:clusterContainer replicas behind an LB
Bun / DenoSingle JS thread event loop, native HTTP stackSame as Node: any long synchronous JSreusePort processes (Linux) or more replicasContainer, or the vendor's edge platform
Python FastAPI + uvicornasyncio loop per worker; def routes in a thread poolBlocking calls inside async def; CPU work (GIL)--workers N processes, or one process per poduvicorn/fastapi run in a container
Go net/httpGoroutine per connection, M:N on GOMAXPROCS threadsLittle: blocking syscalls park the goroutineAutomatic: one process uses every coreSingle static binary in a small image
Rust axum (Tokio)Async tasks on a work-stealing thread poolBlocking or CPU work inside async fn; use spawn_blockingAutomatic: one worker thread per coreStatic 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/OAutomatic: JVM threads use all coresFat JAR or image on the JVM
Java 21+ virtual threadsVirtual thread per request on few carrier threadsPinning (native calls); unbounded downstream loadAutomaticSame JAR with spring.threads.virtual.enabled
C# ASP.NET Core (Kestrel)async/await continuations on the .NET thread poolSync-over-async (.Result, .Wait) starving the poolAutomaticContainer or self-contained single file
Elixir Plug + Bandit (BEAM)Lightweight BEAM process per connection, preemptiveRarely the VM: long NIFs; usually a shared GenServerAutomatic: one scheduler per core; clusters tooMix release in a container
A request through any HTTP serverThe stages are the same everywhere; languages differ in which stage runs on which thread and what a slow stage holds up.
  1. 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.
  2. Parse request headMethod, path, headers. Header-read timeouts belong here (slowloris).
  3. RouteMatch method + path. Unmatched paths become 404; wrong methods 405 in routers that track them.
  4. Read and decode the bodyStream bytes with a size limit, then parse JSON. Malformed input should be a 400, not a 500.
  5. Handler waits on I/ODatabase, cache, other services. This is where the concurrency model matters: what else runs while you wait?
  6. Serialize and writeEncode JSON, set Content-Type, write status and body. Slow clients create backpressure here.
  7. Keep-alive or closeThe connection returns to idle for the next request, until the idle timeout.
  8. 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.
Node.js 24 node:http documentation (opens in new tab)

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.
Bun.serve (Bun 1.2.3+) documentation (opens in new tab)

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.
FastAPI + uvicorn documentation (opens in new tab)

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.
net/http (Go 1.22+ patterns) documentation (opens in new tab)

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.
axum 0.8 + Tokio documentation (opens in new tab)

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.
Spring Boot 4 (Spring MVC) documentation (opens in new tab)

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.
ASP.NET Core 10 minimal APIs documentation (opens in new tab)

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.
Plug.Router + Bandit documentation (opens in new tab)

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?

SOURCES

Explainer reviewed