Why architecture matters here

The value of http4s's purism is concrete, not aesthetic. Because a route is a function returning a value in F, every capability of the effect system applies to HTTP handling uniformly: a timeout is .timeout(2.seconds) on the response effect; cancellation — the client disconnected — propagates through the handler's whole call tree, cancelling the doobie query and releasing the connection, because that is what Cats Effect fibers do; testing needs no server at all, since routes.run(request) is just function application yielding a checkable response. Frameworks bolt these on with interceptors and test harnesses; here they are consequences of the type.

Streaming-first bodies change what is cheap. A response body is Stream[F, Byte]: proxying a 10GB file, streaming a database export as CSV, or relaying an upstream API's chunked response all run in constant memory by default — the stream is pulled by the socket as the client reads, so a slow consumer backpressures through your handler into the database cursor without any code asking for it. The same fact has a sharp edge: a body is a program, not data — read it twice and it may be empty; forget to consume it in a client call and the pooled connection can't be reused until timeout. http4s makes streaming trivially correct and casually incorrect, and knowing which side of that line you're on is the core skill.

Middleware-as-composition is the operational win teams underrate. Auth, logging, metrics, rate limiting, CORS, timeouts, and error translation are each routes => routes functions; the service's policy stack is an explicit, ordered expression in one place — (Timeout(...) andThen Metrics(...) andThen Auth(...))(routes) — reviewable and unit-testable, versus annotation soup where the effective interceptor order is a framework implementation detail. When the incident question is 'was the timeout inside or outside the retry?', http4s answers with a line of code you can read.

Advertisement

The architecture: every piece explained

Top row: the functional core. HttpRoutes[F] is built with a pattern-matching DSL — case GET -> Root / "orders" / OrderId(id) => ... — each case an effectful function; non-matching requests fall through via OptionT to the next route set, and Router("/api" -> apiRoutes, "/admin" -> adminRoutes) mounts by prefix. Middleware composes at two altitudes: over HttpRoutes (can decline to match) or over the sealed HttpApp (total); built-ins cover logging, metrics, CORS, GZip, timeouts, and authentication — AuthedRoutes threading a typed user through matched routes. EntityDecoder/EntityEncoder turn bodies into values and back as effects: req.as[CreateOrder] pulls the byte stream, parses (circe), and validates, with malformed input surfacing as a typed failure your error middleware translates to 400/422. Streaming bodies are the default representation — strict bodies are just the special case of a compiled stream.

Middle row: the runtime edge. Ember server accepts connections as an fs2 TCP stream: each connection a scoped resource, requests parsed incrementally from the byte stream, responses written with chunked transfer where the body length is unknown. Connection management is explicit configuration: keep-alive with idle timeouts, header/request timeouts against slowloris, max connections as the concurrency ceiling, graceful shutdown draining in-flight requests (the server Resource's release). The Ember client mirrors it: a connection pool keyed by host with max-per-host limits, resource-safe request execution (client.run(req).use(resp => ...) — the response body must be consumed inside the use block), retry and follow-redirect as client middleware. WebSockets hand the handler a pair of streams (Pipe[F, WebSocketFrame, WebSocketFrame]); SSE is nothing but a response body streaming ServerSentEvent values — long-lived pushes with backpressure inherited from fs2.

Bottom rows: assembly and observation. Composition patterns scale the codebase: route modules per domain combined with <+>, versioned APIs as mounted routers, authed and public trees separated by middleware boundaries. Integration is value-plumbing: doobie's stream into Ok(rows.map(toCsvLine)); otel4s middleware creating spans per request that propagate through the handler's F. The ops strip names the disciplines: layered timeouts (header, request, idle, per-route), response-body draining on the client, and pool/socket metrics exported like any tier-1 resource.

http4s — HTTP as pure functions over streaming bodiesRequest => F[Response], all the way downHttpRoutesKleisli + OptionTMiddlewarefunction compositionEntityCodecdecode/encode as effectsStreaming bodiesfs2 byte streamsEmber serverpure-FP backendConnection mgmtkeep-alive + timeoutsEmber clientpooled, resource-safeWebSockets + SSEstreams in both directionsCompositionRouter, orElse, authed routesIntegrationcirce, doobie, otel4sOps — timeouts as middleware + body draining + pool metricsservewrapcall outstreammountmanageencodeoperateoperate
http4s: routes are Kleisli functions, middleware is composition, bodies are fs2 streams, and Ember materializes it all on Cats Effect.
Advertisement

End-to-end flow

Assemble and run a real service: an orders API with a streaming export and an upstream call. The application is one Resource composition: config → doobie transactor (pool of 32) → Ember client (pool, 2s total timeout via middleware) → routes → middleware stack (RequestLogger → Metrics(prometheus) → Timeout(5s) → ErrorHandler) → Ember server on 8080. useForever runs it; SIGTERM releases in reverse — server drains, client pool closes, transactor shuts down.

Request one: POST /orders. The route decodes req.as[CreateOrder] (circe; a bad payload short-circuits to the error middleware's 422 with a structured message), calls the domain service — a doobie transaction — and returns 201 with the created entity encoded. The otel4s span tree shows http → service → db with timings; the whole handler ran on one fiber; if the client had disconnected mid-transaction, fiber cancellation would have rolled back cleanly via bracket semantics — not a special case, just Resource discipline all the way down.

Request two: GET /orders/export?month=2026-06 — 4M rows as CSV. The route returns Ok(ordersRepo.streamMonth(month).map(toCsv).through(fs2.text.utf8.encode)): doobie streams the cursor in 512-row chunks, each mapped and encoded, chunked-transfer written to the socket. Heap usage: flat at a few megabytes. A slow client on hotel wifi pulls slowly; backpressure travels socket → stream → cursor; the database does work at the client's pace instead of buffering 800MB in your heap. When the client aborts at row 2M, the socket closes, the stream's scope cancels, and the cursor's finalizer returns the connection — no leak, no code written for any of this.

Request three exercises the client trap and its fix: the handler calls the pricing service via Ember client. Correct form: client.expect[PriceQuote](req) (or run().use with full body consumption). The bug the team once shipped — checking only resp.status inside use and discarding the body — left connections un-reusable until idle timeout; pool wait-time metrics caught it (checkout latency p99 spiked while CPU idled), and the review rule since is mechanical: every client response body is consumed or explicitly drained. The postmortem's chart of pool-borrow wait time is now the canonical local example of why the ops strip lists 'body draining' next to 'timeouts'.