Vultr DocsLatest Content

How to Build Serverless APIs with Kotlin Ktor and Coroutines

Updated on 05 December, 2025
Build a lightweight Kotlin Ktor Product Catalog API optimized for serverless and scalable deployments.
How to Build Serverless APIs with Kotlin Ktor and Coroutines header image

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,

Setting Up Your Development Environment

  1. Verify Java and Gradle.

    console
    $ 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.

    console
    $ unzip product-api.zip -d product-api
    $ cd product-api
    
  4. Run the template to confirm the toolchain.

    console
    $ ./gradlew run
    

    In another terminal:

    console
    $ curl -s http://localhost:8080/ || true
    
  5. 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")
    }
    
  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., call or application scope) 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 and 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
    )
    
  3. Create the in-memory repository file (simulated async) at src/main/kotlin/io/demo/productcatalog/repository/ProductRepository.kt and add the following code.

    kotlin
    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 and add the following code.

    kotlin
    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 and the following module configuration in src/main/kotlin/io/demo/productcatalog/Application.kt.

    kotlin
    fun Application.module() {
        install(ContentNegotiation) { json() }
        configureRouting()
    }
    

    This enables JSON serialization and registers the routing configuration

  6. 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]
    > :run
  7. 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.

    kotlin
    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.

    kotlin
    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.

    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.
kotlin
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-ID header.
  • Emit timing metrics (Micrometer) around integrations.
  • Sample traces on external calls (OpenTelemetry) for latency hotspots.
kotlin
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.

    toml
    [plugins]
    shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" }
    
  2. Add the following content in build.gradle.kts.

    kotlin
    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 & smoke-test locally.

    console
    $ ./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.

    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.

    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/k6 to 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.

Comments