DEV Community

Cover image for Building Serverless APIs with Kotlin Ktor and Coroutines
Sanskriti Harmukh for Vultr

Posted on with Aashish Chaurasiya Originally published at docs.vultr.com

Building Serverless APIs with Kotlin Ktor and Coroutines

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

4. Run the template to confirm the toolchain:

$ ./gradlew run
Enter fullscreen mode Exit fullscreen mode

In another terminal:

$ curl -s http://localhost:8080/ || true
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

6. Wire the plugin in the application:

// src/main/kotlin/io/demo/productcatalog/Application.kt
install(ContentNegotiation) { json() }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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 }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

This enables JSON serialization and registers the routing configuration.

6. Run the application using Gradle:

$ ./gradlew run
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

7. Verify the base routes:

$ curl http://localhost:8080/
$ curl http://localhost:8080/ping
Enter fullscreen mode Exit fullscreen mode

Output:

{"message":"Product Catalog API"}
{"message":"pong"}
Enter fullscreen mode Exit fullscreen mode

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 }
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

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.
install(CallLogging) {
    mdc("requestId") { call.request.headers["X-Request-ID"] ?: UUID.randomUUID().toString() }
}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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("")
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Build and smoke-test locally:

$ ./gradlew shadowJar
$ java -jar build/libs/product-api-all.jar
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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/k6 to tune timeouts, thread pools, and memory.

Next Steps

  • Add authentication/authorization (e.g., JWT) in front of the /products routes.
  • Swap the in-memory ProductRepository for an async-friendly database driver.
  • Wire up the Prometheus metrics endpoint and an OpenTelemetry exporter for production tracing.
  • Load-test the packaged JAR with wrk or k6 to validate timeout and retry settings under concurrency.

For the full guide with additional tips, visit the original article on Vultr Docs.

Top comments (0)