skip to content

Why should a Ktor HttpClient be created once and reused instead of per request?

level: seniorimportance: must knowfreq 55%

answer

  1. It is a resource, not a helper
  2. Engine, pipeline and plugin state per instance
  3. Warm connections and tokens are thrown away
  4. Closeable: unclosed instances accumulate
  5. Derive variants instead of constructing again

basics

~20 s

A Ktor HttpClient owns an engine with threads, connections and plugin state such as cookie storage and cached tokens. Creating one per request multiplies those resources, discards warm connections and state, and leaks unless every instance is closed.

solid answer

~50 s

`HttpClient` is not a lightweight request helper; it is a `Closeable` resource wrapping an engine that holds a dispatcher, threads and a connection pool, plus plugin state — cookies, auth tokens, converters. Constructing one per call means every request pays engine start-up, throws away the connections it just warmed, and loses that state; under load the visible symptoms are climbing thread counts and file-descriptor exhaustion, because instances that are never `close()`d are not reclaimed simply by going out of scope. The correct shape is one long-lived client (commonly one per remote dependency), configured centrally: `defaultRequest { url("https://api.example.com/v1/"); header(...) }` for the base URL and shared headers, and `client.config { ... }` to derive a variant that **reuses the same engine**. Close it during application shutdown; `use { }` is only appropriate in a one-shot script.

code

kotlin · 15 lines
kotlin
// One long-lived client per remote dependency
object OrdersApi {
    private val client = HttpClient(CIO) {
        install(ContentNegotiation) { json() }
        install(HttpTimeout) { requestTimeoutMillis = 5_000 }
        defaultRequest {
            url("https://api.example.com/v1/")   // trailing slash
            header("X-Client", "orders-service")
        }
    }

    suspend fun order(id: String): Order = client.get("orders/$id").body()

    fun shutdown() = client.close()
}

go deeper

for a junior

Recall that HttpClient is a resource you create once and close, not a per-call object, and that it is safe to share across concurrent requests.

for a middle

Explain what the instance owns — engine threads, connection pool, cookie and token state — and why constructing one per call loses all of it and leaks unless closed.

for a senior

Diagnose it in production: threads climbing with traffic, descriptors exhausting, latency that never improves because connections are never reused. Then show the fix — a shared client, defaultRequest for common configuration, config { } for variants, close on shutdown.

for a principal

Own the boundary: one client per remote dependency with its own timeouts and limits, so a failing dependency cannot exhaust capacity another depends on, and the lifecycle is tied to the application's own shutdown sequence.

## What an HttpClient actually owns An `HttpClient` is the sum of three things: 1. **An engine instance** — the transport, with whatever threads, dispatcher and connection pool that engine maintains. 2. **A plugin pipeline** — the installed plugins and their configuration, resolved at construction time. 3. **Plugin state** — anything the plugins accumulate: cookies held by `HttpCookies`, tokens cached by the `Auth` plugin's bearer provider, converter instances from `ContentNegotiation`. All three are per-instance. `HttpClient` implements `Closeable`, and `close()` is what releases the engine's resources. ## What per-request construction costs Creating a client inside a function that runs per request causes several distinct problems at once: - **Setup cost per call.** Engine initialisation and plugin resolution happen every time instead of once. - **Discarded connections.** Each new client starts with an empty pool, so no established connection is ever reused; every call pays full connection setup. - **Lost plugin state.** Cookies gathered on one call are gone on the next; a bearer token obtained by a refresh is not available to the following request, so the auth flow repeats indefinitely. - **Leaked resources.** A client that is never closed keeps its engine alive. Under sustained load this shows up as a thread count that climbs and never falls, and eventually file-descriptor exhaustion. It is a resource leak, not something garbage collection tidies up for you. The failure is quiet in development — a handful of requests, a short-lived process — and loud in production, which is exactly why interviewers ask about it. ## The correct shape One long-lived client, created at application start-up: val client = HttpClient(CIO) { install(ContentNegotiation) { json() } install(HttpTimeout) { requestTimeoutMillis = 5_000 } defaultRequest { url("https://api.example.com/v1/") header("X-Client", "orders-service") } } A client is safe to use concurrently: many coroutines can issue requests through the same instance simultaneously. That is the design, not a workaround. How many clients? The usual rule is **one per remote dependency** rather than one per process or one per call. Different dependencies want different base URLs, timeouts, auth and connection limits, and separate clients also give per-dependency isolation: a slow dependency saturating its own engine's connections does not consume the capacity another dependency needs. ## defaultRequest The `DefaultRequest` plugin, configured with the `defaultRequest { }` shortcut, is where shared request configuration belongs: a base URL, standard headers, a default content type. Individual calls then use relative paths. Keep the base URL's trailing slash and give the per-call path no leading slash — `client.get("orders")` against a base of `https://api.example.com/v1/` resolves under `/v1/`, whereas a leading slash resolves from the host root and drops the prefix. That mismatch is a common and confusing 404. ## Deriving a variant with config When one call site needs different settings, do not construct a second client: val strict = client.config { expectSuccess = true } `HttpClient.config { }` returns a new client with the modified configuration that **reuses the existing engine**, so you get the different behaviour without a second thread pool and connection pool. ## Closing Call `close()` when the owning component shuts down — application stop, a DI container's disposal hook, a test's teardown. In tests especially, a client created per test and never closed will accumulate across the suite. `client.use { ... }` closes at the end of the block and is right for a script or a single-shot tool, and wrong for a service: it would put you straight back into per-request construction. ## How to spot the problem in a running system The fingerprint is a thread count that grows monotonically with request volume and does not recover when traffic drops, often alongside connection or file-descriptor limits being hit. A thread dump showing many identically named engine thread groups is the confirmation. The fix is structural — hoist the client — not a matter of tuning limits upward.

  • What does HttpClient.config { } give you that constructing a second HttpClient does not?
    It returns a new client with the modified configuration while reusing the existing engine, so the variant costs no additional threads or connection pool. Constructing a second client with the same engine factory creates a genuinely separate engine, doubling those resources for what is usually just a settings change.
  • Where should a base URL and shared headers live for a Ktor client?
    In the DefaultRequest plugin via defaultRequest { url(...); header(...) }, so call sites use relative paths. Keep a trailing slash on the base URL and no leading slash on the per-call path; a leading slash resolves from the host root and silently drops the base path prefix.
  • How would you recognise per-request client creation in a running service?
    Thread count climbing with request volume and never recovering, often ending in file-descriptor exhaustion, with a thread dump showing many identically named engine thread groups. Latency also stays high because no connection is ever reused. The fix is to hoist the client, not to raise the limits.

Creating a client per request is like hiring a courier company, waiting for it to lease vans and hire drivers, sending one parcel, then abandoning the company still fully staffed.

saying these in an interview costs you the question

  • Constructs an HttpClient inside each suspend function
  • Says the GC will reclaim an unclosed client
  • Treats HttpClient as a stateless request helper
  • Wraps every single request in client.use { }
  • Duplicates a client just to change one setting

context