How to Build Serverless APIs with Kotlin Ktor and Coroutines
How to Build Serverless APIs with Kotlin Ktor and Coroutines
Learn how to create efficient serverless APIs using Kotlin Ktor and Coroutines. This guide covers setup, implementation, and best practices for modern API development.

Modern serverless platforms demand fast startup times, low resource overhead, and efficient concurrency. Kotlin, with its modern syntax and coroutine-based concurrency, and Ktor, its lightweight asynchronous web framework, provide an ideal combination for building high-performance serverless APIs.
This article demonstrates how to build a Product Catalog CRUD API using Kotlin and Ktor. You’ll learn how to structure a coroutine-driven application, handle concurrent requests efficiently, and package it for deployment across cloud providers in fully serverless or containerized environments.
Prerequisites
Before you begin,
- Have access to an Ubuntu 24.04 based server as a non-root user with sudo privileges.
- Install JDK 17+ (OpenJDK recommended). You can use the official Java downloads or your distro’s OpenJDK packages.
- Use the project’s Gradle wrapper (
./gradlew). Installing Gradle system-wide is optional.
Setting Up Your Development Environment
- Verify Java and Gradle.
console
$ java -version $ ./gradlew -v || echo "Gradle wrapper will be generated by Ktor project"
- Generate a new project at start.ktor.io with:
- Group:
io.demo - Artifact:
product-api - Package:
io.demo.productcatalog - Dependencies: Server Core, Netty, Routing, Content Negotiation (kotlinx-serialization)
- Group:
- Unpack and open the project.
console
$ unzip product-api.zip -d product-api $ cd product-api
- Run the template to confirm the toolchain.
console
$ ./gradlew runIn another terminal:
console$ curl -s http://localhost:8080/ || true
- Ensure JSON serialization is enabled (add if missing).
kotlin
// build.gradle.kts dependencies { implementation("io.ktor:ktor-server-core-jvm") implementation("io.ktor:ktor-server-netty-jvm") implementation("io.ktor:ktor-server-content-negotiation-jvm") implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") implementation("io.ktor:ktor-server-config-yaml-jvm") implementation("io.ktor:ktor-server-call-logging-jvm") implementation("io.ktor:ktor-server-compression-jvm") implementation("io.ktor:ktor-server-caching-headers-jvm") implementation("io.ktor:ktor-server-request-timeout-jvm") implementation("io.ktor:ktor-server-metrics-micrometer-jvm") implementation("ch.qos.logback:logback-classic:1.5.6") }
- Wire the plugin in the application.
kotlin
// src/main/kotlin/io/demo/productcatalog/Application.kt install(ContentNegotiation) { json() }
Understanding Ktor and Coroutines for Serverless
Ktor uses non-blocking I/O and Kotlin coroutines to handle high concurrency with low memory overhead, ideal for short-lived, autoscaled serverless workloads.
- Event loop + suspending handlers: Handlers suspend on I/O instead of blocking threads, so a few threads serve many requests.
- Coroutines vs. threads: Coroutines are lightweight; you can run thousands without the context-switch cost of OS threads.
- Structured concurrency: Scope your launches (e.g.,
callorapplicationscope) to avoid leaks across requests. - Backpressure: Keep endpoints non-blocking; offload CPU-heavy or blocking work to
Dispatchers.Default/custom pools. - Cold starts: Trim dependencies and init work; defer heavy setup until first use.
Building Your First Serverless API with Ktor
Build a Product Catalog CRUD API to demonstrate routing, JSON serialization, and coroutine-friendly data access.
- Create the project structure.
src/ └── main/ ├── kotlin/io/demo/productcatalog/ │ ├── Application.kt │ ├── Routing.kt │ ├── model/Product.kt │ └── repository/ProductRepository.kt └── resources/ ├── application.yaml └── logback.xml - Create the model file at
src/main/kotlin/io/demo/productcatalog/model/Product.ktand add the following code.kotlin@Serializable data class Product( val id: String = UUID.randomUUID().toString(), val name: String, val price: Double, val stock: Int ) @Serializable data class ProductCreate( val name: String, val price: Double, val stock: Int )
- Create the in-memory repository file (simulated async) at
src/main/kotlin/io/demo/productcatalog/repository/ProductRepository.ktand add the following code.kotlinobject ProductRepository { private val products = mutableListOf<Product>() init { products += Product(name = "Laptop", price = 1200.0, stock = 20) products += Product(name = "Smartphone", price = 800.0, stock = 10) products += Product(name = "Headphones", price = 150.0, stock = 30) } suspend fun all(): List<Product> { delay(50) return products.toList() } suspend fun find(id: String): Product? { delay(50) return products.find { it.id == id } } suspend fun add(product: Product): Product { delay(50) products.add(product) return product } suspend fun update(id: String, product: Product): Product? { delay(50) val idx = products.indexOfFirst { it.id == id } return if (idx != -1) product.copy(id = id).also { products[idx] = it } else null } suspend fun delete(id: String): Boolean { delay(50) return products.removeIf { it.id == id } } }
- Create the routing configuration file at
src/main/kotlin/io/demo/productcatalog/Routing.ktand add the following code.kotlinfun Application.configureRouting() { routing { get("/") { call.respond(mapOf("message" to "Product Catalog API")) } get("/ping") { call.respond(mapOf("message" to "pong")) } get("/products") { call.respond(ProductRepository.all()) } get("/products/{id}") { val id = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.BadRequest) ProductRepository.find(id)?.let { call.respond(it) } ?: call.respond(HttpStatusCode.NotFound) } post("/products") { val dto = call.receive<ProductCreate>() if (dto.name.isBlank() || dto.price < 0 || dto.stock < 0) { return@post call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid product: name must be non-blank, price and stock ≥ 0")) } val saved = ProductRepository.add(Product(name = dto.name, price = dto.price, stock = dto.stock)) call.respond(HttpStatusCode.Created, saved) } put("/products/{id}") { val id = call.parameters["id"] ?: return@put call.respond(HttpStatusCode.BadRequest) val dto = call.receive<ProductCreate>() if (dto.name.isBlank() || dto.price < 0 || dto.stock < 0) { return@put call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid product: name must be non-blank, price and stock ≥ 0")) } val updated = ProductRepository.update(id, Product(name = dto.name, price = dto.price, stock = dto.stock)) updated?.let { call.respond(it) } ?: call.respond(HttpStatusCode.NotFound) } delete("/products/{id}") { val id = call.parameters["id"] ?: return@delete call.respond(HttpStatusCode.BadRequest) if (ProductRepository.delete(id)) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound) } } }
- Edit the application file and the following module configuration in
src/main/kotlin/io/demo/productcatalog/Application.kt.kotlinfun Application.module() { install(ContentNegotiation) { json() } configureRouting() }
This enables JSON serialization and registers the routing configuration
- Run the application using Gradle.
console
$ ./gradlew runOutput:
> Task :run 2025-10-13 15:19:55.869 [main] INFO Application - Autoreload is disabled because the development mode is off. 2025-10-13 15:19:56.298 [main] INFO Application - Application started in 0.774 seconds. 2025-10-13 15:19:56.629 [DefaultDispatcher-worker-1] INFO Application - Responding at http://0.0.0.0:8080 <==========---> 83% EXECUTING [19m 10s] > :run - Verify the base routes.
console
$ curl http://localhost:8080/ $ curl http://localhost:8080/ping
Output:
{"message":"Product Catalog API"} {"message":"pong"}
Handling Concurrency and Performance
Ktor serves each request on a lightweight coroutine, so a handful of threads can handle heavy concurrency. Tune the app to stay non-blocking, predictable, and fast.
- Keep handlers non-blocking. Use
withContext(Dispatchers.IO)for blocking work (file I/O, JDBC) so you don’t stall the event loop.kotlinsuspend fun readFile(path: Path): String = withContext(Dispatchers.IO) { Files.readString(path) }
- Use structured concurrency in routes. Launch child coroutines in a scope and
await()them together. If one fails, Ktor cancels siblings and the request.kotlinget("/report") { coroutineScope { val a = async { service.fetchA() } // suspend fun val b = async { service.fetchB() } call.respond(mapOf("a" to a.await(), "b" to b.await())) } }
- Right-size dispatchers (only when needed). Let Ktor manage event loops. If you must isolate heavy I/O/CPU work, bound the pool and close it on shutdown.
kotlin
val ioPool = Executors.newFixedThreadPool(8).asCoroutineDispatcher() environment.monitor.subscribe(ApplicationStopped) { ioPool.close() }
- Remove artificial delays in production. Replace
delay()in repositories with real async I/O (database, HTTP client). - Add low-cost server features. Compression, caching headers, and request timeouts improve latency and protect the app.
kotlin
fun Application.module() { install(ContentNegotiation) { json() } install(Compression) { gzip() } install(CachingHeaders) { options { CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 60)) } } install(RequestTimeout) { requestTimeoutMillis = 5_000 } configureRouting() }
Integrating External Services
Connect to databases and APIs with suspendable, time-bounded calls. Add retries sparingly, log context, and protect upstreams.
Ktor HTTP Client (timeouts, retries, JSON)
- Configure a resilient Ktor HTTP client with JSON, timeouts, and limited retries.
kotlin
val http = HttpClient(CIO) { expectSuccess = true install(ContentNegotiation) { json() } install(HttpTimeout) { requestTimeoutMillis = 2_000 connectTimeoutMillis = 1_000 socketTimeoutMillis = 2_000 } // Ktor's retry feature (Ktor 2.x) install(HttpRequestRetry) { retryOnServerErrors(maxRetries = 2) exponentialDelay(base = 200, maxDelayMs = 1_000) // Optionally retry on specific exceptions: retryIf { _, cause -> cause is HttpRequestTimeoutException } retryOnExceptionIf { _, cause -> cause is java.io.IOException } } defaultRequest { header("Accept", "application/json") } }
- Add client dependencies to your build file.
kotlin
// build.gradle.kts dependencies { implementation("io.ktor:ktor-client-core-jvm") implementation("io.ktor:ktor-client-cio-jvm") implementation("io.ktor:ktor-client-content-negotiation-jvm") implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") implementation("io.ktor:ktor-client-http-timeout-jvm") implementation("io.ktor:ktor-client-retry-jvm") testImplementation("io.ktor:ktor-client-mock-jvm") }
- Close the shared client on shutdown to prevent resource leaks.
kotlin
environment.monitor.subscribe(ApplicationStopped) { http.close() }
- Inject the client and base URL for testability. Example with a repository and a MockEngine-based test setup:
kotlin
class PriceRepository( private val client: HttpClient, private val baseUrl: String ) { suspend fun fetchPrices(): List<Price> = client.get("$baseUrl/prices").body() } // wiring (e.g., in Application.module) val pricesBaseUrl: String = environment.config.propertyOrNull("app.services.prices.baseUrl")?.getString() ?: System.getenv("PRICES_BASE_URL") ?: "https://api.example.com" val priceRepository = PriceRepository(http, pricesBaseUrl) // test setup val mockClient = HttpClient(MockEngine) { engine { addHandler { request -> if (request.url.encodedPath.endsWith("/prices")) { respond( content = "[{\"sku\":\"A\",\"value\":10.0}]", headers = headersOf("Content-Type" to listOf("application/json")) ) } else error("Unhandled ${'$'}{request.url}") } } install(ContentNegotiation) { json() } } val mockRepo = PriceRepository(mockClient, "https://mock.local")
- Call the client from a repository; keep functions
suspend, add per-call timeouts if a dependency is flaky.kotlin@Serializable data class Price(val sku: String, val value: Double) object PriceService { suspend fun fetchPrices(): List<Price> = http.get("https://api.example.com/prices").body() }
- Per-call timeout override:
kotlin
suspend fun fetchPricesFast(): List<Price> = http.get("https://api.example.com/prices") { timeout { requestTimeoutMillis = 1_000 } }.body()
Minimal “circuit breaker” guard (lightweight)
- If an upstream often times out, short-circuit for a brief window. Keep it simple without extra libs.
kotlin
class SimpleBreaker( private val threshold: Int = 5, private val openMillis: Long = 5_000 ) { @Volatile private var failures = 0 @Volatile private var openedAt = 0L fun allow(): Boolean = if (System.currentTimeMillis() - openedAt > openMillis) true else failures < threshold fun recordSuccess() { failures = 0; openedAt = 0 } fun recordFailure() { failures++ if (failures >= threshold) openedAt = System.currentTimeMillis() } } val breaker = SimpleBreaker() suspend fun guardedPrices(): List<Price> { if (!breaker.allow()) return emptyList() // fallback return try { val data = PriceService.fetchPrices() breaker.recordSuccess() data } catch (e: Exception) { breaker.recordFailure() emptyList() // degrade gracefully } }
Database integration tips (JDBC/JPA/R2DBC)
- JDBC/JPA: treat calls as blocking; wrap in
withContext(Dispatchers.IO). Keep pools small but sufficient. - R2DBC / async drivers: prefer truly non-blocking drivers when available.
- Set short query timeouts and fail fast on connection errors.
suspend fun findProductById(id: String): Product? =
withContext(Dispatchers.IO) {
dataSource.connection.use { conn ->
conn.prepareStatement("SELECT ... WHERE id = ?").use { ps ->
ps.queryTimeout = 2 // seconds
ps.setString(1, id)
ps.executeQuery().use { rs -> /* map to Product */ null }
}
}
}
Observability (logs, metrics, traces)
- Log request IDs: propagate a
X-Request-IDheader. - Emit timing metrics (Micrometer) around integrations.
- Sample traces on external calls (OpenTelemetry) for latency hotspots.
install(CallLogging) {
mdc("requestId") { call.request.headers["X-Request-ID"] ?: UUID.randomUUID().toString() }
}
Hardening checklist
- Time-box every external call (client + per-call timeouts).
- Add bounded retries with exponential backoff (no infinite retries).
- Use fallback/cached responses for non-critical paths.
- Validate and sanitize all inbound/outbound payloads.
- Keep secrets in environment variables or a secret store; never hard-code.
Deploying and Monitoring Across Cloud Providers
Ship a portable artifact, run it the same way everywhere, and expose health/metrics for hands-off ops.
Package a single, portable JAR
Serverless/container platforms don’t resolve Gradle deps, so build a fat JAR.
- Add the following content in
libs.versions.toml.toml[plugins] shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" }
- Add the following content in
build.gradle.kts.kotlinplugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.plugin.serialization) alias(libs.plugins.ktor) alias(libs.plugins.shadow) } application { mainClass.set("io.demo.productcatalog.ApplicationKt") } tasks { shadowJar { archiveBaseName.set("product-api") archiveClassifier.set("") archiveVersion.set("") } }
- Build & smoke-test locally.
console
$ ./gradlew shadowJar $ java -jar build/libs/product-api-all.jar
Make the app cloud-friendly (port, health, graceful shutdown)
- Configure the application port and external services in
resources/application.yaml.yamlktor: deployment: port: ${PORT:8080} # use env var PORT if provided application: modules: - io.demo.productcatalog.ApplicationKt.module app: services: prices: baseUrl: ${PRICES_BASE_URL:https://api.example.com}
This configuration allows the application to bind to a dynamic port provided by the cloud platform and defines an external service base URL through environment variables.
- Add lightweight ops features.
kotlin
fun Application.module() { install(ContentNegotiation) { json() } install(CallLogging) install(Compression) { gzip() } // optional timeouts to avoid hung requests install(RequestTimeout) { requestTimeoutMillis = 5_000 } routing { get("/ready") { call.respond(mapOf("status" to "ok")) } // readiness get("/live") { call.respond(mapOf("status" to "ok")) } // liveness } configureRouting() }
These changes make the application suitable for cloud platforms by supporting dynamic port binding, health probes for orchestration systems, request logging, response compression, and graceful request timeouts.
Observability: logs, metrics, traces (portable setup)
- Structured logs:
kotlin
install(CallLogging) // keep message rates low, include request ids if available
- Prometheus metrics:
kotlin
// build.gradle.kts: implementation("io.micrometer:micrometer-registry-prometheus:<version>") val registry = io.micrometer.prometheus.PrometheusMeterRegistry(io.micrometer.prometheus.PrometheusConfig.DEFAULT) install(io.ktor.server.metrics.micrometer.MicrometerMetrics) { this.registry = registry } routing { get("/metrics") { call.respondText(registry.scrape()) } }
Advanced Tips for Optimizing Ktor in Serverless Environments
- Keep dependencies lean. Every jar adds cold-start time.
- Avoid blocking the event loop. Wrap unavoidable blocking in a bounded IO dispatcher.
- Warm paths on deploy. Optionally pre-load serializers/config.
- Cache smartly. Memoize hot, static reads with short TTLs.
- Benchmark routinely. Use
wrk/k6to tune timeouts, thread pools, and memory.
Conclusion
In this article, you built a lightweight Product Catalog CRUD API with Kotlin and Ktor, validated it locally, and packaged it as a portable shadow JAR for serverless or container platforms. You leveraged coroutines and non-blocking I/O to handle concurrent load efficiently with a small runtime footprint. Next steps include adding authentication/authorization, persisting data with an async-friendly driver, integrating external services, and wiring metrics and tracing for production readiness.