Every app needs to talk to the internet. On Android, you use Retrofit. On iOS, you use URLSession. Two different libraries, two different APIs, two different codebases.
In KMP, you use Ktor Client — one networking library that works on every platform. Write your API calls once in commonMain, and they work on Android, iOS, Desktop, and Web.
What is Ktor?
Ktor is a networking framework by JetBrains (the same team behind Kotlin). The client side lets you make HTTP requests from shared code.
The key idea: you write networking logic in commonMain using Ktor’s common API. Each platform plugs in its own HTTP engine:
commonMain: Ktor Client API (your code)
↓
androidMain: OkHttp engine (uses OkHttp under the hood)
iosMain: Darwin engine (uses URLSession under the hood)
desktopMain: CIO engine (pure Kotlin engine)
You don’t interact with OkHttp or URLSession directly. Ktor wraps them behind a common API.
Setup
Dependencies
Add to your shared/build.gradle.kts:
// In gradle/libs.versions.toml
[versions]
ktor = "3.0.0"
kotlinx-serialization = "1.7.3"
kotlinx-coroutines = "1.9.0"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
Why Three Engine Dependencies?
ktor-client-core is the common API — it defines HttpClient, get(), post(), etc. But it doesn’t know HOW to make actual network calls. That’s what engines do:
- OkHttp (Android) — uses the battle-tested OkHttp library
- Darwin (iOS) — uses Apple’s native URLSession
- CIO (Desktop/Server) — pure Kotlin coroutine-based engine
You add the engine in the platform-specific source set. Your shared code never knows which engine is running.
Creating the HttpClient
In commonMain, create a shared HttpClient:
// shared/src/commonMain/kotlin/network/HttpClientFactory.kt
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
// Creates an HttpClient that works on every platform
// The engine is provided by each platform's source set
fun createHttpClient(): HttpClient {
return HttpClient {
// JSON serialization — automatically converts JSON to Kotlin objects
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true // Don't crash on extra JSON fields
prettyPrint = true // Readable logs
isLenient = true // Accept slightly malformed JSON
})
}
// Logging — see requests/responses in the console
install(Logging) {
level = LogLevel.BODY // Log everything (use NONE in production)
}
}
}
ignoreUnknownKeys = true is important. Real APIs often return more fields than your data class has. Without this, Ktor would crash on any unexpected field.
Making GET Requests
Define Data Models
// shared/src/commonMain/kotlin/models/User.kt
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: Int,
val name: String,
val email: String,
val phone: String = "" // Default value for optional fields
)
Create the API Client
// shared/src/commonMain/kotlin/network/UserApiClient.kt
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
class UserApiClient(private val client: HttpClient) {
private val baseUrl = "https://jsonplaceholder.typicode.com"
// GET request — fetch all users
suspend fun getUsers(): List<User> {
return client.get("$baseUrl/users").body()
}
// GET request with path parameter
suspend fun getUserById(id: Int): User {
return client.get("$baseUrl/users/$id").body()
}
// GET request with query parameters
suspend fun searchUsers(name: String): List<User> {
return client.get("$baseUrl/users") {
url {
parameters.append("name", name)
}
}.body()
}
}
Notice:
suspend fun— network calls are suspending functions (non-blocking).body()— Ktor automatically deserializes JSON into your data class- No manual JSON parsing —
ContentNegotiation+@Serializablehandle it
Use It
// In a ViewModel or any shared code
val client = createHttpClient()
val api = UserApiClient(client)
// Fetch all users
val users = api.getUsers()
// Fetch one user
val user = api.getUserById(1)
This code runs on Android AND iOS. Same API client, same data models, same result.
Making POST Requests
// Data class for the request body
@Serializable
data class CreateUserRequest(
val name: String,
val email: String
)
// Data class for the response
@Serializable
data class CreateUserResponse(
val id: Int,
val name: String,
val email: String
)
class UserApiClient(private val client: HttpClient) {
// POST request — send data to the server
suspend fun createUser(name: String, email: String): CreateUserResponse {
return client.post("$baseUrl/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(name = name, email = email))
}.body()
}
}
setBody() automatically serializes your Kotlin object to JSON. contentType(ContentType.Application.Json) tells the server you’re sending JSON.
Error Handling
Network calls fail. The server might be down, the user might have no internet, or the API might return an error. Always handle errors:
// shared/src/commonMain/kotlin/network/ApiResult.kt
// A sealed class that represents success or failure
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String) : ApiResult<Nothing>()
}
class UserApiClient(private val client: HttpClient) {
suspend fun getUsersSafe(): ApiResult<List<User>> {
return try {
val users = client.get("$baseUrl/users").body<List<User>>()
ApiResult.Success(users)
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
}
}
}
Use it in the ViewModel:
class UserListViewModel(private val api: UserApiClient) : ViewModel() {
private val _state = MutableStateFlow(UserListState())
val state: StateFlow<UserListState> = _state.asStateFlow()
init { loadUsers() }
private fun loadUsers() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
when (val result = api.getUsersSafe()) {
is ApiResult.Success -> {
_state.update { it.copy(users = result.data, isLoading = false) }
}
is ApiResult.Error -> {
_state.update { it.copy(error = result.message, isLoading = false) }
}
}
}
}
}
This pattern works identically on Android and iOS.
Platform-Specific Engines
How Ktor Picks the Engine
When you call HttpClient { } without specifying an engine, Ktor picks the one available on the current platform. Since you added ktor-client-okhttp in androidMain and ktor-client-darwin in iosMain, the right engine is used automatically.
Custom Engine Configuration
If you need platform-specific settings, use expect/actual:
// commonMain — declare the factory
expect fun createPlatformHttpClient(): HttpClient
// androidMain — configure OkHttp engine
actual fun createPlatformHttpClient(): HttpClient {
return HttpClient(OkHttp) {
engine {
config {
connectTimeout(10, TimeUnit.SECONDS)
readTimeout(15, TimeUnit.SECONDS)
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
}
// iosMain — configure Darwin engine
actual fun createPlatformHttpClient(): HttpClient {
return HttpClient(Darwin) {
engine {
configureRequest {
setAllowsCellularAccess(true)
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
}
Most apps don’t need this. The default HttpClient { } with auto-detected engine works fine.
Complete Example: API Client + ViewModel
Here is a complete, production-ready networking setup:
Data Models
// shared/src/commonMain/kotlin/models/Post.kt
@Serializable
data class Post(
val id: Int,
val userId: Int,
val title: String,
val body: String
)
API Client
// shared/src/commonMain/kotlin/network/PostApiClient.kt
class PostApiClient(private val client: HttpClient) {
private val baseUrl = "https://jsonplaceholder.typicode.com"
suspend fun getPosts(): ApiResult<List<Post>> {
return try {
val posts = client.get("$baseUrl/posts").body<List<Post>>()
ApiResult.Success(posts)
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Failed to load posts")
}
}
suspend fun getPostById(id: Int): ApiResult<Post> {
return try {
val post = client.get("$baseUrl/posts/$id").body<Post>()
ApiResult.Success(post)
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Failed to load post")
}
}
suspend fun createPost(title: String, body: String, userId: Int): ApiResult<Post> {
return try {
val post = client.post("$baseUrl/posts") {
contentType(ContentType.Application.Json)
setBody(Post(id = 0, userId = userId, title = title, body = body))
}.body<Post>()
ApiResult.Success(post)
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Failed to create post")
}
}
// Don't forget to close the client when done
fun close() {
client.close()
}
}
Using on Android (Compose)
@Composable
fun PostListScreen() {
val client = remember { createHttpClient() }
val api = remember { PostApiClient(client) }
var posts by remember { mutableStateOf<List<Post>>(emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
when (val result = api.getPosts()) {
is ApiResult.Success -> {
posts = result.data
isLoading = false
}
is ApiResult.Error -> {
error = result.message
isLoading = false
}
}
}
// Display posts...
}
Using on iOS (Swift)
Kotlin suspend functions are exposed to Swift as completion-handler callbacks. The exact API depends on your KMP configuration. A typical pattern:
import Shared
class PostViewModel: ObservableObject {
@Published var posts: [Post] = []
func loadPosts() {
// KMP generates async/callback wrappers for suspend functions
// The exact syntax depends on your Kotlin/Native export settings
// Check your generated framework headers for the available API
}
}
Note: Kotlin-Swift interop for suspend functions is improving rapidly. Libraries like SKIE or KMP-NativeCoroutines make suspend functions work naturally in Swift with async/await.
Same API client. Same data models. Two platforms.
Ktor vs Retrofit
| Ktor Client | Retrofit | |
|---|---|---|
| Platforms | Android, iOS, Desktop, Web | Android only |
| Language | Kotlin (multiplatform) | Kotlin/Java (JVM only) |
| JSON parsing | Kotlin Serialization | Gson, Moshi, or Kotlin Serialization |
| Coroutines | Native | Adapter needed |
| API definition | Functions in a class | Interface with annotations |
| Engine | Pluggable (OkHttp, Darwin, CIO) | OkHttp only |
| Use for | KMP / cross-platform | Android-only projects |
If you are building KMP, use Ktor. If you are building Android-only, both work — Retrofit has a larger ecosystem but Ktor is more flexible.
Common Mistakes
Mistake 1: Forgetting ContentNegotiation
// BAD — no JSON parsing, .body() returns raw text
val client = HttpClient()
// GOOD — JSON automatically parsed to data classes
val client = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
Mistake 2: Not Handling Errors
// BAD — crashes on network error
val users = client.get("$baseUrl/users").body<List<User>>()
// GOOD — catches errors gracefully
try {
val users = client.get("$baseUrl/users").body<List<User>>()
} catch (e: Exception) {
// Handle error
}
Mistake 3: Wrong Engine Dependency
// BAD — adding OkHttp in commonMain (won't work on iOS)
commonMain.dependencies {
implementation(libs.ktor.client.okhttp) // Android only!
}
// GOOD — engine in platform source set
commonMain.dependencies {
implementation(libs.ktor.client.core) // Common API
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp) // Android engine
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin) // iOS engine
}
Mistake 4: Forgetting @Serializable
// BAD — Ktor can't parse JSON without this
data class User(val id: Int, val name: String)
// GOOD — Kotlin Serialization knows how to parse this
@Serializable
data class User(val id: Int, val name: String)
Mistake 5: Not Closing the Client
// BAD — leaks connections
fun loadData() {
val client = HttpClient { ... }
val data = client.get("...").body()
// client never closed!
}
// GOOD — close when done, or create once and reuse
class ApiClient : Closeable {
private val client = createHttpClient()
override fun close() {
client.close()
}
}
Quick Reference
| Action | Code |
|---|---|
| Create client | HttpClient { install(ContentNegotiation) { json() } } |
| GET request | client.get("url").body<Type>() |
| POST request | client.post("url") { setBody(data) }.body<Type>() |
| Query params | url { parameters.append("key", "value") } |
| Set headers | headers { append("Authorization", "Bearer token") } |
| Set timeout | install(HttpTimeout) { requestTimeoutMillis = 10000 } |
| Close client | client.close() |
Source Code
The KMP tutorial project is on GitHub:
Related Tutorials
- KMP Tutorial #3: Project Structure — commonMain/androidMain source sets where Ktor code lives
- Compose Tutorial #12: Retrofit — Android-only networking (compare with Ktor)
- KMP Tutorial #2: Setup — setting up the KMP project
What’s Next?
In the next tutorial, we will learn about SQLDelight — a multiplatform database that works like Room but runs on Android AND iOS. Combined with Ktor, you can build an offline-first app with shared networking and storage.
See you there.