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 guide demonstrates how to build a Product Catalog CRUD API using Kotlin and Ktor, structure a coroutine-driven application, handle concurrent requests efficiently, and package it for deployment across cloud providers in fully serverless or containerized environments. By the end, you'll have a working CRUD API, hardened for external calls, packaged as a portable JAR ready for serverless or container deployment.
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).
- Use the project's Gradle wrapper (
./gradlew). Installing Gradle system-wide is optional.
Setting Up Your Development Environment
1. Verify Java and Gradle:
$ java -version
$ ./gradlew -v || echo "Gradle wrapper will be generated by Ktor project"
2. 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)
3. Unpack and open the project:
$ unzip product-api.zip -d product-api
$ cd product-api
4. Run the template to confirm the toolchain:
$ ./gradlew run
In another terminal:
$ curl -s http://localhost:8080/ || true
5. Ensure JSON serialization is enabled (add if missing):
// 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")
}
6. Wire the plugin in the application:
// 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.
1. 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
2. Create the model file at src/main/kotlin/io/demo/productcatalog/model/Product.kt:
@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
)
3. Create the in-memory repository file (simulated async) at src/main/kotlin/io/demo/productcatalog/repository/ProductRepository.kt:
object 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 }
}
}
4. Create the routing configuration file at src/main/kotlin/io/demo/productcatalog/Routing.kt:
fun 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)
}
}
}
5. Edit the application file with the following module configuration in src/main/kotlin/io/demo/productcatalog/Application.kt:
fun Application.module() {
install(ContentNegotiation) { json() }
configureRouting()
}
This enables JSON serialization and registers the routing configuration.
6. Run the application using Gradle:
$ ./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]
> :run
7. Verify the base routes:
$ 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.
suspend 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.
get("/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.
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.
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.
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.
// 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.
environment.monitor.subscribe(ApplicationStopped) { http.close() } -
Inject the client and base URL for testability. Example with a repository and a MockEngine-based test setup:
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.
@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:
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.
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.
1. Add the following content in libs.versions.toml:
[plugins]
shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" }
2. Add the following content in build.gradle.kts:
plugins {
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("")
}
}
3. Build and smoke-test locally:
$ ./gradlew shadowJar
$ java -jar build/libs/product-api-all.jar
Make the app cloud-friendly (port, health, graceful shutdown)
1. Configure the application port and external services in resources/application.yaml:
ktor:
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.
2. Add lightweight ops features:
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:
install(CallLogging) // keep message rates low, include request ids if available -
Prometheus metrics:
// 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.
Next Steps
- Add authentication/authorization (e.g., JWT) in front of the
/productsroutes. - Swap the in-memory
ProductRepositoryfor an async-friendly database driver. - Wire up the Prometheus metrics endpoint and an OpenTelemetry exporter for production tracing.
- Load-test the packaged JAR with
wrkork6to validate timeout and retry settings under concurrency.
For the full guide with additional tips, visit the original article on Vultr Docs.
Top comments (0)