
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 run
In 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.xmlCreate 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 run
Output:
> 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] > :runVerify 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.
kotlinval 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.
kotlinfun 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.
kotlinval 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.
kotlinenvironment.monitor.subscribe(ApplicationStopped) { http.close() }
Inject the client and base URL for testability. Example with a repository and a MockEngine-based test setup:
kotlinclass 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:
kotlinsuspend 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.
kotlinclass 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.
kotlinfun 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:
kotlininstall(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.