1. Introduction
When you first start writing networking code, your hand reaches for the dream: “I want one line so the response arrives immediately, gets parsed immediately, everything works immediately, and if it doesn’t — well… somehow.” The problem is that the network is the champion of surprises: the server may respond with the wrong thing, the internet may drop, the API may return HTML instead of JSON, and you’ll be sitting there thinking Kotlin “broke”.
The “without magic” idea means: we do every step explicitly. We explicitly get an HttpResponse, explicitly check the status, explicitly read the body (as text), and only then decide what to do next. This makes the code a bit longer, but much easier to debug. And yes: Ktor is one of the popular Kotlin tools for HTTP (both client and server). It’s often mentioned as a Kotlin framework for backend tasks.
HttpClient: one object, many requests, one responsibility
HttpClient in Ktor is the “main control panel” for network requests. Its key idea: create the client once and reuse it. If you create a new client for every request, you’ll get extra connections, extra resources, and one day — strange slowdowns (or “why does it hang only on the third request?”).
Another key point: you must close HttpClient. It holds resources (connections, threads), and if you don’t release them, at best the program will behave sloppily, and at worst you’ll see mysterious leaks and hangs. So from the very beginning we get used to discipline: try/finally and client.close().
As a reference point, it helps to keep a simple table of differences in mind:
| Entity | What it is | Lives for | Why you need it |
|---|---|---|---|
|
a “machine” that can do HTTP | usually the whole main/the whole scenario | configure “how we go to the network” |
|
a specific response to a specific request | one request | read status, headers, body |
The engine and why CIO often appears in examples
On the JVM, Ktor Client can work through different engines. In plain terms, an engine is the “internal transport implementation”: what exactly the client will use to establish connections and move bytes. For learning examples, people often use CIO because it works well as a clear default for the JVM.
Right now our goal is very simple: learn to explicitly create a client so the student (that is, you) doesn’t feel like half the work was done “in secret”.
A small note about dependencies: you’ll need ktor-client-core and a JVM engine (for example, ktor-client-cio). In a real project, versions are chosen to match your Gradle/Kotlin, but as an example you may see something like io.ktor:ktor-client-core:2.3.11.
2. Minimal client and the first request
Minimal client setup: HttpClient(CIO) with no extra layers
Our first task is to make a function that creates an HttpClient. Yes, it sounds funny (“a function that returns an object”), but it’s an important architectural reflex: if client creation lives in one place, you won’t multiply it across the project like rabbits you forgot to lock in a cage.
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
fun buildClient(): HttpClient {
return HttpClient(CIO)
}
Notice: there are no plugins, timeouts, or “smart” settings yet. We intentionally keep the complexity minimal: first we learn to walk, then to run, then to do flips (but not in prod, please).
runBlocking in main: how to make the console and suspend get along
Ktor Client is coroutine-based. This means network operations are usually done from suspend code, because waiting for a response shouldn’t block a thread “head-on”. In a console app we have a regular fun main(), and it’s not suspend. So we use a “bridge” — runBlocking { ... }, which you’ve already seen in the day about coroutines.
It’s important to treat runBlocking as a learning/console tool: it starts coroutines and waits for them to finish. Inside this block we can safely call client.get(...).
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = HttpClient(CIO)
println("Client created") // Client created
client.close()
println("Client closed") // Client closed
}
This example seems useless, but it reinforces two key movements: “created” and “closed”. If you train yourself to do this now, there will be less pain later.
First request “without magic”: get HttpResponse and read bodyAsText()
Now let’s make the simplest GET request. What’s fundamentally important: we don’t try to “get a string immediately” or “get an object immediately”. We get an HttpResponse, and then, as a separate step, read the body.
To make the example actually runnable, people often use test services like https://httpbin.org/get (it returns JSON, but for now we read it as plain text).
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = HttpClient(CIO)
try {
val response: HttpResponse = client.get("https://httpbin.org/get")
val text: String = response.bodyAsText()
println("status=${response.status.value}") // status=200 (if everything is OK)
println("body length=${text.length}") // body length=...
} finally {
client.close()
}
}
Pay attention to the style: we do not hide close() “somewhere later”. It sits in finally, because the network is a place where “later” may not happen (exception, error, cancellation — anything).
Why bodyAsText() is a separate step, and why it’s convenient
Sometimes a student asks: “Why not make get() return a string right away?” You can… but then you lose the structure of the HTTP response. And that structure matters. The status code, headers, and body are three different parts of the contract, and mixing them into one mush is tempting, but bad for debugging.
There’s also a more practical point: reading the body is an operation. You may want to check the status first and then decide whether to read the body as a “success” or as an “error text”. Even if for now we only do bodyAsText(), the habit of “separating steps” is gold.
Let’s try writing a small helper function that “turns a response into understandable data”. We’re not getting into JSON and models yet (that will be a separate topic), but we can already format the result as a data class.
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
data class HttpRawResponse(
val status: Int,
val body: String
)
suspend fun HttpResponse.toRaw(): HttpRawResponse {
val text = bodyAsText()
return HttpRawResponse(status = status.value, body = text)
}
By the way, there’s an important “adult” advantage hidden here: now you have an extension point. Today it returns status and text; tomorrow you’ll add headers; the day after tomorrow — tracing; but the calling code will remain readable.
3. Request configuration and a small refactor
Request builder: where headers and query parameters live
Almost any real GET request sooner or later turns from “just a URL” into “URL + parameters + headers”. In Ktor this is configured via the request builder — the block in braces after get(...). It’s important not to confuse the roles: in this block we build the request, but we don’t handle the response.
Let’s show an example where we add Accept and a query parameter q. We intentionally do this “the grown-up way”, without manually concatenating the string "?q=kotlin&limit=10" (because manual concatenation is like writing passwords on sticky notes: fast, but embarrassing later).
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
suspend fun demoSearch(client: HttpClient) {
val response = client.get("https://httpbin.org/get") {
header(HttpHeaders.Accept, ContentType.Application.Json)
url { parameters.append("q", "kotlin") }
}
println("status=${response.status.value}") // status=200
println("body=${response.bodyAsText().take(40)}...") // body={ ... }...
}
Here we can already see a nice boundary: “how we build the request” is inside { ... }, “what we do with the response” is after we get response.
A small refactor: move the network call into a function
As soon as you have a second request, main starts to bloat. This is a normal stage of a program growing up: first everything is in one place, then you start extracting functions. Now we’ll take a very small but useful step: create a function that does a GET and returns HttpRawResponse.
Notice: the function is suspend, because it does networking inside. And main remains the “conductor”: it creates the client, calls functions, and prints the result.
import io.ktor.client.HttpClient
import io.ktor.client.request.get
suspend fun getText(client: HttpClient, url: String): HttpRawResponse {
val response = client.get(url)
return response.toRaw()
}
And usage:
import io.ktor.client.engine.cio.CIO
import io.ktor.client.HttpClient
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = HttpClient(CIO)
try {
val r = getText(client, "https://httpbin.org/get")
println("status=${r.status}") // status=200
println("body length=${r.body.length}") // body length=...
} finally {
client.close()
}
}
This refactor seems small, but it enforces architectural discipline: networking code stops being “just a snippet in main” and becomes a separate unit that’s easier to read, test, and evolve.
A small diagram: what happens during a “simple text load”
To lock the model into your head, it helps to imagine executing a request as a sequence of steps. Yes, it looks “obvious”, but these kinds of obvious diagrams save you when something goes wrong and you’re staring at the code at 2 a.m.
sequenceDiagram
participant M as main/runBlocking
participant C as HttpClient
participant S as Server (URL)
participant R as HttpResponse
M->>C: get(url)
C->>S: HTTP request (GET)
S-->>C: HTTP response (status + headers + body)
C-->>R: HttpResponse
M->>R: status
M->>R: bodyAsText()
R-->>M: String (body text)
The key idea: HttpResponse is not “the response text”. It’s an object from which the text (or other body forms) is extracted as a separate step.
4. Common mistakes when working with HttpClient
Mistake No. 1: creating HttpClient inside every request function.
A beginner often writes fun load() { val client = HttpClient(...); client.get(...); } and repeats this in five places. At first it seems “fine”, but then strange delays appear, extra connections, and it’s unclear where to close resources. It’s better to keep one client at a higher level and pass it into functions.
Mistake No. 2: forgetting to close HttpClient.
If the client is not closed, resources may hang until the process ends, and sometimes the program will behave as if it “doesn’t finish” (especially if you later add background operations). Get used to try/finally { client.close() } like brushing your teeth: boring, but useful.
Mistake No. 3: trying to “read the body” and “check the status” in one messy blob.
When code turns into a bed sheet, you start catching logic errors: for example, parsing the body as if it’s a success even though you got a 404 or 500. Even if for now you’re just printing text, keep the steps separate: first response.status, then bodyAsText(), then the decision.
Mistake No. 4: concatenating a URL with query parameters manually.
Strings like "https://site/api?q=" + query + "&limit=" + limit very quickly lead to bugs with ?/&, spaces, and escaping. Ktor provides url { parameters.append(...) } — use it. It’s not “beauty for beauty’s sake”, it’s prevention for “why doesn’t the server understand my request?”
Mistake No. 5: reading the body multiple times and being surprised by weirdness.
The response body is a data stream, and in different implementations repeated reading can be problematic or expensive. The practical style is simple: call bodyAsText() once, store it in a variable, and work with that string afterward. It’s both faster and clearer during debugging.
Mistake No. 6: trying to make a network call without coroutines and being surprised the compiler complains.
client.get(...) is a suspend operation, and Kotlin honestly won’t let you call it from a regular function. It’s not “Kotlin being picky”, it’s protection against blocking and chaotic async code. The solution is predictable: either you’re inside runBlocking (in the console), or you’re in a suspend function.
GO TO FULL VERSION