Back to Blog

HTTP & APIs in Compose Desktop: Ktor Client & JSON Serialization | Kotlin Desktop #8

Sandy LaneSandy Lane

Video: HTTP & APIs in Compose Desktop: Ktor Client & JSON Serialization | Kotlin Desktop #8 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

HTTP and APIs in Compose Desktop: Build a Weather App

Ktor Client for the HTTP. kotlinx.serialization for JSON. LaunchedEffect and rememberCoroutineScope to bridge async to Compose.

Today's app fetches real data from a real API. Pick a city, see its current weather — temperature, wind speed, weather code — pulled live from Open-Meteo (free, no key required).

The new pieces:

  • Ktor Client — JetBrains' multiplatform HTTP client. Coroutine-based, JSON-aware via kotlinx.serialization.
  • LaunchedEffect — run a coroutine when a Composable enters composition.
  • rememberCoroutineScope — get a scope you can launch into from event handlers.

What we are building

A 900×600 window with:

  • A city selector (Tokyo / London / New York buttons) plus a refresh icon.
  • A loading spinner while fetching.
  • A weather card showing temperature, wind speed, weather code, and timestamp.
  • An error message if the request fails.

Project layout

src/main/kotlin/
├── Main.kt              # window
├── AppContent.kt        # state machine + UI
├── WeatherApi.kt        # Ktor client + fetchWeather
└── WeatherModels.kt     # @Serializable response types

build.gradle.kts dependencies

dependencies {
  implementation(compose.desktop.currentOs)
  implementation(compose.material3)
  implementation(compose.materialIconsExtended)
  implementation("io.ktor:ktor-client-core:2.3.12")
  implementation("io.ktor:ktor-client-cio:2.3.12")
  implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
  implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

Four Ktor artefacts. core is the API. cio is the JVM engine (CIO = Coroutine-based I/O). content-negotiation plus kotlinx-json plug in JSON parsing.

Don't forget the kotlin("plugin.serialization") plugin in the plugins block — without it, @Serializable annotations are ignored.

WeatherModels.kt: response types

@Serializable
data class WeatherResponse(
  val latitude: Double,
  val longitude: Double,
  val current: CurrentWeather,
)

@Serializable
data class CurrentWeather(
  val time: String,
  @SerialName("temperature_2m") val temperature: Double,
  @SerialName("weather_code") val weatherCode: Int,
  @SerialName("wind_speed_10m") val windSpeed: Double,
)

The shape of the JSON Open-Meteo returns. @SerialName maps the API's snake_case keys to Kotlin's idiomatic camelCase fields.

@Serializable is doing the work — at compile time, the kotlinx.serialization plugin generates serialize and deserialize code. No reflection, no runtime cost.

WeatherApi.kt: the Ktor client

val client = HttpClient(CIO) {
  install(ContentNegotiation) {
    json(Json { ignoreUnknownKeys = true })
  }
}

data class City(val name: String, val latitude: Double, val longitude: Double)

val cities = listOf(
  City("Tokyo", 35.6762, 139.6503),
  City("London", 51.5074, -0.1278),
  City("New York", 40.7128, -74.0060),
)

suspend fun fetchWeather(city: City): WeatherResponse {
  return client.get("https://api.open-meteo.com/v1/forecast") {
    parameter("latitude", city.latitude)
    parameter("longitude", city.longitude)
    parameter("current", "temperature_2m,weather_code,wind_speed_10m")
  }.body()
}

Three pieces.

HttpClient(CIO) { install(ContentNegotiation) { json(...) } } builds a Ktor client with the CIO engine and JSON content negotiation. ignoreUnknownKeys = true is forgiving — if Open-Meteo adds new fields to the response, we won't crash.

fetchWeather(city) is suspend, so it can await async work. Inside, client.get(url) { parameter(...) } builds a request with query parameters. The .body() call parses the response JSON into our WeatherResponse type — Ktor uses the registered JSON encoder and the @Serializable annotation to deserialise.

The actual URL becomes:

https://api.open-meteo.com/v1/forecast?latitude=35.6762&longitude=139.6503&current=temperature_2m,weather_code,wind_speed_10m

AppContent.kt: bridging to Compose

@Composable
fun AppContent() {
  var selectedCity by remember { mutableStateOf(cities[0]) }
  var weather by remember { mutableStateOf<WeatherResponse?>(null) }
  var loading by remember { mutableStateOf(false) }
  var error by remember { mutableStateOf<String?>(null) }

  val scope = rememberCoroutineScope()

  fun loadWeather(city: City) {
    loading = true
    error = null
    scope.launch {
      try {
        weather = fetchWeather(city)
      } catch (e: Exception) {
        error = "Failed to fetch weather: ${e.message}"
      } finally {
        loading = false
      }
    }
  }

  LaunchedEffect(Unit) { loadWeather(selectedCity) }

  // UI...
}

Four state pieces:

  • selectedCity — which city's weather to show.
  • weather — the loaded data, or null while loading/error.
  • loading — true while a request is in flight.
  • error — error message string, or null.

This is the async-state pattern — every async fetch has these four states. Whatever your data source, the pattern is the same.

val scope = rememberCoroutineScope() gets a coroutine scope tied to this Composable's lifetime. When the Composable leaves composition, the scope cancels — pending requests stop.

fun loadWeather(city) is a regular function that uses scope.launch { ... } to fire a coroutine. The coroutine awaits fetchWeather, sets state on success or error, clears loading in finally. Standard try/catch/finally for async UX.

LaunchedEffect for initial load

LaunchedEffect(Unit) { loadWeather(selectedCity) }

LaunchedEffect(key) runs a suspending lambda when the Composable first enters composition. If key changes, the previous coroutine is cancelled and a new one starts. Passing Unit means "run once, never re-trigger."

For "load when this state changes," use the state as the key:

LaunchedEffect(selectedCity) { loadWeather(selectedCity) }

That would fetch on city changes automatically — but in this app we trigger fetches imperatively from button clicks, so we use Unit for the initial load only.

Rendering based on state

when {
  loading -> CircularProgressIndicator()
  error != null -> Text(error!!, color = MaterialTheme.colorScheme.error)
  weather != null -> WeatherCard(weather!!)
}

A three-branch when:

  • Loading: spinner.
  • Error: message in error colour.
  • Loaded: the weather card.

The else case (none of the above) renders nothing — fine when those states never co-occur.

For more elaborate UIs, model the state explicitly:

sealed class WeatherUiState {
  object Initial : WeatherUiState()
  object Loading : WeatherUiState()
  data class Error(val message: String) : WeatherUiState()
  data class Success(val data: WeatherResponse) : WeatherUiState()
}

Then var state by remember { mutableStateOf<WeatherUiState>(Initial) } and when (state) { ... }. Type-safe; the compiler enforces exhaustiveness.

WeatherCard

@Composable
fun WeatherCard(data: WeatherResponse) {
  Card(
    modifier = Modifier.fillMaxWidth(),
    colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
  ) {
    Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
      Text("Temperature: ${data.current.temperature}°C", style = MaterialTheme.typography.headlineMedium)
      Text("Wind Speed: ${data.current.windSpeed} km/h", style = MaterialTheme.typography.bodyLarge)
      Text("Weather Code: ${data.current.weatherCode}", style = MaterialTheme.typography.bodyLarge)
      Text("Time: ${data.current.time}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
    }
  }
}

Pure render. Takes the response, lays it out. No state, no callbacks.

Error handling beyond the basics

The simple error = e.message catches anything thrown. Real apps want more granular handling:

try {
  weather = fetchWeather(city)
} catch (e: ResponseException) {
  error = "API error ${e.response.status.value}"
} catch (e: ConnectTimeoutException) {
  error = "Network timeout. Check your connection."
} catch (e: SerializationException) {
  error = "Couldn't parse response."
} catch (e: Exception) {
  error = "Unexpected: ${e.message}"
}

Specific messages for specific failures. The user knows whether to retry, check their network, or report a bug.

Cancelling requests

When the user clicks a different city while a request is in flight, you want to cancel the previous one. The simplest fix: store the Job and cancel it.

var currentJob by remember { mutableStateOf<Job?>(null) }

fun loadWeather(city: City) {
  currentJob?.cancel()
  currentJob = scope.launch {
    // fetch
  }
}

For more elaborate cases, use Flow with flatMapLatest or migrate to a real ViewModel pattern (Lesson 16).

Common mistakes

Calling fetchWeather directly from a Composable body. It's a suspend function; you can't call it from sync code. Use LaunchedEffect or scope.launch.

Forgetting ignoreUnknownKeys. Without it, the API adding a new field crashes your app. Always set it true unless you specifically want strict parsing.

Hardcoding the API URL. For dev/staging/prod environments, externalise.

No timeout. Ktor has reasonable defaults but they're long. For UX, set explicit timeouts: install(HttpTimeout) { requestTimeoutMillis = 5_000 }.

Hitting an API thousands of times in a tight loop. Rate limits, costs, performance. Debounce inputs that trigger requests.

What's next

Lesson 9: database and storage. Persist data locally with Exposed ORM and SQLite. Same pattern (suspend operations, state, error handling) applied to local CRUD instead of remote HTTP.

Recap

Ktor HttpClient(CIO) plus ContentNegotiation plus kotlinx.serialization for typed JSON responses. @SerialName for snake_case → camelCase field mapping. rememberCoroutineScope() plus scope.launch { ... } for triggering async work from event handlers. LaunchedEffect(key) for "fetch when this Composable enters composition." Three-state UI: loading, error, loaded.

Next lesson: database and storage.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.