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.jl

The 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) = HEALTH

Streaming 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 = 7

The 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
end

HTTP/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.