Server Guide
HTTP.jl 2.0 supports both high-level request handlers and lower-level stream handlers. The right choice depends on how much control you need over read/write sequencing.
Interactive Thread Pool
HTTP.jl schedules its server tasks on Julia's :interactive thread pool. This includes listener, connection, request-handler, HTTP/2 stream, server-side SSE, and WebSocket server tasks. Keeping this work separate from the :default pool allows the server to accept and handle requests, including health checks, while the default pool runs compute-intensive tasks that may not yield.
Configure at least one interactive thread when starting a production server. For example, this command creates four default worker threads and one interactive thread:
julia --threads=4,1 --project=. server.jlThe equivalent environment setting is JULIA_NUM_THREADS=4,1. Check the live configuration with Threads.nthreads(:interactive), which should return at least 1.
If no interactive thread exists, Julia runs tasks requested for :interactive on the default pool. The server still starts, but it loses isolation from default-pool work. Non-yielding compute tasks can then delay all HTTP work and make health checks appear unresponsive.
Interactive tasks should remain responsive. Do not run long, non-yielding compute kernels directly in a server handler. Move that work to the default pool and wait for it from the handler so the interactive task can yield:
result = fetch(Threads.@spawn :default expensive_work())A non-yielding handler can still monopolize the interactive pool. The separate pool protects HTTP work from compute tasks assigned to :default; it cannot make non-yielding handler code cooperative.
Request Handlers
Use HTTP.serve! or HTTP.serve when your application naturally maps Request -> Response.
using HTTP
server = HTTP.serve!("127.0.0.1", 0; listenany = true) do req
payload = "handled " * req.target
return HTTP.Response(
200;
headers = ["X-Handler" => "request"],
body = payload,
)
end
base_url = "http://127.0.0.1:$(HTTP.port(server))"
resp = HTTP.get(base_url * "/health"; proxy = HTTP.ProxyConfig())
HTTP.forceclose(server)
(status = resp.status, header = HTTP.header(resp, "X-Handler"), body = String(resp.body))This is the simplest server path and the best default for ordinary APIs.
Reusing Responses
A Response built from a String or a Vector{UInt8} body keeps that value as-is, so one response object can be returned for many requests ("baked" responses), on both the request-handler and stream-handler paths:
const HEALTH = HTTP.Response(200; headers = ["Content-Type" => "text/plain"], body = "ok")
handler(req) = HEALTHStreaming bodies (HTTP.BytesBody, HTTP.CallbackBody, and the bodies of incoming messages) are single-use. Use a new streaming body for each response. HTTP checks BytesBody and CallbackBody before sending the response head; do not rely on this check for other AbstractBody implementations.
Stream Handlers
Use HTTP.listen! when you need lower-level ownership of the connection lifecycle. HTTP.streamhandler is the bridge when you want stream server mechanics with a request-style handler body.
using HTTP
stream_server = HTTP.listen!(
HTTP.streamhandler() do req
return HTTP.Response(201; body = "stream handler")
end,
"127.0.0.1",
0;
listenany = true,
)
stream_url = "http://127.0.0.1:$(HTTP.port(stream_server))"
stream_resp = HTTP.get(stream_url * "/echo"; status_exception = false, proxy = HTTP.ProxyConfig())
HTTP.forceclose(stream_server)
(status = stream_resp.status, body = String(stream_resp.body))Stream handlers are the right tool when you need:
- pull-based request body reads
- push-based or incremental response writing
- trailers or custom sequencing
- long-lived handlers that cannot be expressed as a single eager
Response
Server Lifecycle
The returned Server handle is operationally important. Hold onto it so you can:
- inspect the bound port with
HTTP.port(server) - block on completion with
wait(server) - close or force-close the server explicitly during shutdown
HTTP.forceclose(server) is the fast shutdown path when you need to stop accepting and serving immediately.
Every server timeout has both a seconds-valued keyword and a nanosecond-valued _ns keyword:
using HTTP
handler = req -> HTTP.Response(200; body = "ok")
server = HTTP.serve!(
handler,
"127.0.0.1",
8080;
read_header_timeout_ns = 5_000_000_000,
read_timeout_ns = 30_000_000_000,
write_timeout_ns = 30_000_000_000,
idle_timeout_ns = 120_000_000_000,
)using HTTP
handler = req -> HTTP.Response(200; body = "ok")
server = HTTP.serve!(
handler,
"127.0.0.1",
8080;
read_timeout = 30,
read_header_timeout = 5,
write_timeout = 30,
idle_timeout = 120,
)The older readtimeout keyword is accepted as a seconds-valued migration alias for read_timeout.
Ordinary serve! request handlers receive a buffered HTTP.Request body. That buffering is capped by max_body_bytes, which defaults to 64 MiB. Raise the limit for larger in-memory uploads, pass max_body_bytes = 0 to restore legacy unbounded buffering, or use listen!/stream handlers when the application should manage large request bodies incrementally.
Routing and Middleware
Use HTTP.Router when you want route matching without bringing in a larger web framework:
using HTTP
router = HTTP.Router()
HTTP.register!(router, "GET", "/users/{id}") do req
id = HTTP.getparam(req, "id")
return HTTP.Response(200; body = "user " * id)
end
server = HTTP.serve!(router, "127.0.0.1", 8080)Middleware is just function composition around handlers. For example, apply a handler timeout to every registered route:
using HTTP
timeout = HTTP.Handlers.handlertimeout(5.0; status = 503)
router = HTTP.Router(
req -> HTTP.Response(404),
req -> HTTP.Response(405),
timeout,
)The router stores route metadata on the request context. Read it with HTTP.getroute, HTTP.getparams, and HTTP.getparam.
Request Logging
HTTP.Handlers.logging_middleware is an opt-in access log. It wraps a handler and emits one log record per request through Julia's logging system:
using HTTP, Logging
server = HTTP.serve!(HTTP.Handlers.logging_middleware(router), "127.0.0.1", 8080)With the default ConsoleLogger, each request prints a record like:
┌ Info: GET /users/42 200 0.412ms
│ method = "GET"
│ target = "/users/42"
│ status = 200
│ elapsed_ms = 0.412
└ length = 7The record carries the method, the target, the response status, the handler time in milliseconds, and the response body length when it is known without reading the body. A handler that throws is logged at Logging.Error with the exception, and the exception is rethrown so the server still answers with an error status. Pass level to log successful requests at another level, and logger to send the records to a specific logger instead of the current one:
access_log = HTTP.Handlers.logging_middleware(router; level = Logging.Debug)The same wrapper works for HTTP.listen! stream handlers, where the record also carries the client peer address. All records use the :access log group, so they are easy to filter or route with a package such as LoggingExtras.jl.
Static Files
HTTP.fileserver(root) returns a normal request handler rooted at a directory. It serves static files, normalizes directory redirects, can fall back to a single-page-app entrypoint, and emits conditional and range-aware responses.
using HTTP
handler = HTTP.fileserver("public"; spa_fallback = "index.html")
server = HTTP.serve!(handler, "127.0.0.1", 8080)For lower-level control, use HTTP.servefile(request, path) when you already resolved a filesystem path, or HTTP.servecontent(request, source) when the bytes/string/seekable IO content is already in hand. These helpers populate content type, Last-Modified, ETag, Accept-Ranges, and Content-Range headers as appropriate, and honor conditional and range request headers before returning a Response.
SSE and Long-Lived Responses
HTTP.jl exposes SSEEvent, SSEStream, and sse_stream for server-sent events. Use these when you want a proper text/event-stream response instead of hand-assembling event lines.
using HTTP
server = HTTP.serve!("127.0.0.1", 8080) do req
return HTTP.sse_stream(200) do stream
write(stream, HTTP.SSEEvent("ready"; event = "status", id = "1"))
end
endHTTP/2 Servers
The same server entrypoints can serve HTTP/2. For browser and most production clients, configure TLS so ALPN can select h2; for cleartext prior-knowledge clients, HTTP.jl accepts the HTTP/2 connection preface on the normal listener. Most applications do not need a separate server API for HTTP/2; use the normal serve!, listen!, and streamhandler surfaces.