Rust VS. Kotlin

8/7/2025

Introduction

Why Rust and Kotlin?

I'm first and foremost a developer writing applications that run on the JVM (Java Virtual Machine), using languages such as Kotlin, Scala and Java (in my order of preference). However, it's impossible to ignore the remarkable strides Rust has been making lately, especially when it comes to tooling.

Let's look at some examples:

The list goes on and on. Suffice to say that Rust is making a strong impression on the software industry by improving the efficiency and performance of many tools and applications which makes it worth investing time and effort into evaluating the technology.

What we'll do

I'm curious to know how Rust compares to Kotlin (JVM) when building standard HTTP APIs. Rust is less ergonomic (in my opinion) than Kotlin when it comes to writing code, but if the performance benefits are convincing enough it might be worth building new services in Rust instead.

Keep in mind that this is a very opinionated performance comparison. I'll select libraries that I like to work with for both Rust and Kotlin and I won't really nitpick on small changes in performance, I expect to see a big differences in resource utilization.

We will start off with building two REST APIs, one in Rust and the other in Kotlin, aiming for an identical feature set that we will then exercise with a load test. We'll take a look at CPU, memory usage and request latencies.

Common Stack

Rust & Kotlin Stack

Below are the libraries we'll use listed with their corresponding counterpart in both languages.

KotlinRustDescription
ktoraxumHTTP server library
jdbisqlxRDBMS communication and conversion of objects to- and from PostgreSQL
HikariCPN/ARDBMS Connection pooling manager (sqlx has a good one built-in already)
jacksonserdeJSON conversion
kotlinx.coroutinestokioAsync runtime for non-blocking IO
koinN/ADependency injection for Kotlin (Axum kind of has this functionality built-in)

Code

The Database Schema

Let's create a very simple database schema. We'll have a customer that can purchase products. That's it. I don't think this needs to be much more complicated than this. We don't want the database to be the main focus in these tests so we need just enough to make sure we have at least some meaningful queries.

CREATE TABLE customer
(
    id   SERIAL NOT NULL,
    name TEXT   NOT NULL,
    PRIMARY KEY (id)
);
 
CREATE TABLE sale
(
    id               SERIAL                                NOT NULL,
    customer_id      INT REFERENCES customer (id)          NOT NULL,
    transaction_date TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (id)
);
 
CREATE TABLE product
(
    id         SERIAL NOT NULL,
    name       TEXT   NOT NULL,
    price_100x INT    NOT NULL,
    PRIMARY KEY (id)
);
 
CREATE TABLE product_sale
(
    sale_id    INT NOT NULL REFERENCES sale (id),
    product_id INT NOT NULL REFERENCES product (id),
    quantity      INT NOT NULL,
    UNIQUE(sale_id, product_id)
);

With the above schema we got:

You can freely browse all the code on GitHub ❤. I've managed to contain both the Rust and Kotlin code in a single file, both less than 400 lines. I hope it's readable enough 😅.

Let's start by taking a look at the Kotlin service code.

Kotlin Web Service

package com.nightlybits.bench
 
// Imports omitted for brevity
 
object Service
private val logger = LoggerFactory.getLogger(Service::class.java)
 
@Serializable
data class CustomerSession(val customerId: Int)
 
data class Config(
    val pgHost: String,
    val pgPort: Int,
    val pgDatabase: String,
    val pgUser: String,
    val pgPassword: String
) {
    companion object {
        fun fromEnvOrDefault() = Config(
            pgHost = System.getenv("PG_HOST") ?: "localhost",
            pgPort = System.getenv("PG_PORT")?.toInt() ?: 5432,
            pgDatabase = System.getenv("PG_DATABASE") ?: "postgres",
            pgUser = System.getenv("PG_USER") ?: "postgres",
            pgPassword = System.getenv("PG_PASSWORD") ?: "password"
        )
    }
}
 
fun main() {
    val config = Config.fromEnvOrDefault()
 
    val jdbi = configureJdbi(config)
    val diModule = module {
        single<Jdbi> { jdbi }
        single<Database.CustomerDao> { jdbi.onDemand(Database.CustomerDao::class.java) }
    }
 
    embeddedServer(
        Netty,
        port = 9050,
        module = {
            install(Sessions) {
                cookie<CustomerSession>("customer_session") {
                    cookie.path = "/"
                    cookie.maxAgeInSeconds = Duration.ofHours(2).toSeconds()
                }
            }
 
            install(Authentication) {
                session<CustomerSession>("auth") {
                    validate { session ->
                        session
                    }
                    challenge {
                        call.respond(HttpStatusCode.Unauthorized)
                    }
                }
            }
 
            install(StatusPages) {
                exception<Throwable> { call, cause ->
                    @Suppress("UNUSED_EXPRESSION")
                    when (cause) {
                        else -> {
                            logger.warn("Caught an unknown error", cause)
                            call.respondText(
                                text = "Unexpected Error",
                                status = HttpStatusCode.InternalServerError
                            )
                        }
                    }
                }
            }
 
            install(ContentNegotiation) {
                jackson {
                    registerModule(JavaTimeModule())
                    disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                    disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
                }
            }
 
            install(Koin) {
                slf4jLogger()
                modules(diModule)
            }
 
            routing {
                storeApi()
            }
        }
    ).start(wait = true)
}
 
object Database {
 
    interface CustomerDao {
 
        @Language("PostgreSQL")
        @SqlQuery("INSERT INTO customer(name) VALUES (:name) RETURNING id, name")
        fun insertCustomer(name: String): CustomerRecord
 
        @Language("PostgreSQL")
        @SqlQuery("INSERT INTO sale(customer_id) VALUES(:customerId) RETURNING id, transaction_date")
        fun insertSale(customerId: Int): SaleRecord
 
        @Language("PostgreSQL")
        @SqlUpdate("""
        INSERT INTO product_sale(
            sale_id,
            product_id,
            quantity
        ) VALUES (
            :saleId,
            :productId,
            :count
        )
        """)
        fun insertPurchase(saleId: Int, productId: Int, count: Int)
 
        @Language("PostgreSQL")
        @SqlQuery("""
        SELECT product_sale.sale_id  AS sale_id,
               product.id            AS product_id,
               product.name          AS product_name,
               product.price_100x    AS product_price_100x,
               product_sale.quantity AS quantity,
               sale.transaction_date AS transaction_date
        FROM product
                 JOIN product_sale ON product.id = product_sale.product_id
                 JOIN sale ON product_sale.sale_id = sale.id
        WHERE sale.customer_id = :customerId
        """)
        fun listPurchases(customerId: Int): List<PurchaseRecord>
    }
 
    data class CustomerRecord(
        @ColumnName("id")
        val id: Int,
 
        @ColumnName("name")
        val name: String
    )
 
    data class SaleRecord(
        @ColumnName("id")
        val id: Int,
 
        @ColumnName("transaction_date")
        val date: Timestamp
    )
 
    data class PurchaseRecord(
        @ColumnName("sale_id")
        val saleId: Int,
 
        @ColumnName("product_id")
        val productId: Int,
 
        @ColumnName("product_name")
        val productName: String,
 
        @ColumnName("product_price_100x")
        val price100x: String,
 
        @ColumnName("quantity")
        val quantity: Int,
 
        @ColumnName("transaction_date")
        val date: Timestamp
    )
}
 
object Http {
 
    data class CreateCustomerDTO(val name: String)
 
    data class ProductDTO(
        val id: Int,
        val quantity: Int
    )
 
    data class CreatePurchaseDTO(
        val products: List<ProductDTO>
    )
 
    data class SaleDTO(
        val saleId: Int,
        val productName: String,
        val price100x: String,
        val count: Int,
        val date: Instant
    )
 
    fun Routing.storeApi() {
        val jdbi by inject<Jdbi>()
        val customerDao by inject<Database.CustomerDao>()
 
        get("/healthcheck") {
            call.respond(HttpStatusCode.OK, mapOf("status" to "ok"))
        }
 
        post("/customer") {
            val body = call.receive<CreateCustomerDTO>()
 
            val customer = withContext(Dispatchers.IO) {
                customerDao.insertCustomer(body.name)
            }
 
            call.sessions.set(CustomerSession(customer.id))
 
            call.respond(HttpStatusCode.OK)
        }
 
        authenticate("auth") {
            post("/purchase") {
                val customerSession = call.principal<CustomerSession>()
                if (customerSession == null) {
                    call.respond(HttpStatusCode.Unauthorized)
                    return@post
                }
 
                val body = call.receive<CreatePurchaseDTO>()
 
                withContext(Dispatchers.IO) {
                    jdbi.useTransaction<Exception> { txnHandle ->
                        val dao = txnHandle.attach(Database.CustomerDao::class.java)
                        val sale = dao.insertSale(customerSession.customerId)
 
                        body.products.forEach { product ->
                            dao.insertPurchase(
                                sale.id,
                                product.id,
                                product.quantity
                            )
                        }
                    }
                }
                call.respond(HttpStatusCode.OK)
            }
 
            get("/purchase") {
                val customerSession = call.principal<CustomerSession>()
                if (customerSession == null) {
                    call.respond(HttpStatusCode.Unauthorized)
                    return@get
                }
 
                val purchases = withContext(Dispatchers.IO) {
                    customerDao.listPurchases(customerSession.customerId)
                }
 
                val responseBody = purchases.map { purchase ->
                    SaleDTO(
                        saleId = purchase.saleId,
                        productName = purchase.productName,
                        price100x = purchase.price100x,
                        count = purchase.quantity,
                        date = Instant.ofEpochMilli(purchase.date.time)
                    )
                }
 
                call.respond(responseBody)
            }
        }
    }
}
 
private fun configureJdbi(config: Config): Jdbi {
    val hikariConfig = HikariConfig().apply {
        jdbcUrl = with(config) {
            "jdbc:postgresql://$pgHost:$pgPort/$pgDatabase?tcpKeepAlive=true"
        }
        username = config.pgUser
        password = config.pgPassword
        maximumPoolSize = 10
        minimumIdle = 2
        idleTimeout = 30000
        connectionTimeout = 30000
        maxLifetime = 1800000
    }
 
    val hikariDataSource = HikariDataSource(hikariConfig)
    return Jdbi.create(hikariDataSource)
        .installPlugin(PostgresPlugin())
        .installPlugin(KotlinSqlObjectPlugin())
}

You might be surprised to discover that I've managed to fit the entire application in a single file. Java, and by proxy, the JVM languages have worked up a reputation for being very verbose and sluggish. I'm happy to tell you that Kotlin is a game changer in the ergonomics department, featuring an incredibly rich standard library with pretty much anything you can think of. Kotlin is also developed by JetBrains who also make IntelliJ IDEA which makes IDE integration with the language excellent.

Kotlin Dockerfile

Next up we want a way to build and run our Kotlin service. Below is the Dockerfile that builds and runs the Kotlin service.

FROM debian:bookworm-slim AS java-base
 
RUN apt-get update && apt-get install -y --no-install-recommends \
    wget \
    gnupg \
    curl \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*
 
RUN mkdir -p /etc/apt/keyrings && \
    curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor > /etc/apt/keyrings/adoptium.gpg && \
    echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list
 
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    temurin-21-jdk && \
    rm -rf /var/lib/apt/lists/*
 
ENV JAVA_HOME=/usr/lib/jvm/temurin-21-jdk-amd64/
ENV PATH="$JAVA_HOME/bin:$PATH"
 
 
FROM java-base AS build
 
WORKDIR /build
 
COPY ["gradle/", "./gradle"]
COPY ["settings.gradle.kts", "build.gradle.kts", "gradlew", "./"]
COPY ["src/", "./src"]
 
RUN --mount=type=cache,target=/root/.gradle/caches \
    --mount=type=cache,target=/root/.gradle/wrapper \
    --mount=type=cache,target=/build/.gradle \
    ./gradlew shadowJar --no-daemon
 
 
FROM java-base
 
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    sysstat \
    procps && \
    rm -rf /var/lib/apt/lists/* && \
    apt-get clean
 
RUN useradd --create-home --shell /bin/bash appuser
 
WORKDIR /home/appuser
 
COPY --from=build --chown=appuser:appuser ./build/build/libs/kotlin-service-0.1.jar /home/appuser/service.jar
 
USER appuser
 
EXPOSE 9050
 
CMD ["/bin/bash", "-c", "java ${ENABLE_JFR:+-XX:StartFlightRecording=filename=flight_recording.jfr} -jar service.jar & pidstat -u -r -p $! --human 1 > process_metrics.txt"]

The file consists of three stages,

  1. A java-base image which is really just a debian image with Java installed.
  2. The build stage compiles and bundles all classes into a self-contained distribution also known as a fat jar or shadow jar. This allows us to easily invoke the service with java -jar <jar_with_all_dependencies>.jar.
  3. At last we launch the application while continuously polling the service CPU and memory usage with pidstat each second and store the result in process_metrics.txt.

About the Java Flight Recorder

We've also included an option that enables the Java Flight Recorder (JFR). This allows us to run the actual benchmarks without JFR so that we don't interfere with the benchmarking results, but lets us run tests on the side to gather more detailed metrics for the JVM. This is very useful for figuring out how much of the OS reported memory usage is actually in use by the application in the JVM. We need this because the JVM reserves a pool of memory from the operating system called the committed heap space which resizes depending on how much memory the application needs. For example, The OS might tell us that the JVM process is using 100MB of memory but when looking at the JFR data we can tell that only 80MB of that data is actually in use. In practice this means that there is always a buffer of unused memory that's allocated by the JVM. This is one of the reasons the JVM is often considered a memory hog, but it's not the only reason. I'd love to expand further on this topic but let's save it for a future post 🙂.

Rust Web Service

// Imports omitted for brevity
 
struct Config {
    pg_host: String,
    pg_port: u16,
    pg_database: String,
    pg_user: String,
    pg_password: String,
}
 
impl Config {
    fn load_from_env() -> Self {
        use std::env::var;
 
        Config {
            pg_host: var("PG_HOST").unwrap_or_else(|_| "localhost".to_string()),
            pg_port: var("PG_PORT")
                .unwrap_or_else(|_| "5432".to_string())
                .parse()
                .expect("expected environment variable 'PG_PORT' to be a number"),
            pg_database: var("PG_DATABASE").unwrap_or_else(|_| "postgres".to_string()),
            pg_user: var("PG_USER").unwrap_or_else(|_| "postgres".to_string()),
            pg_password: var("PG_PASSWORD").unwrap_or_else(|_| "password".to_string()),
        }
    }
}
 
static SESSION_COOKIE_NAME: &str = "session_token";
 
#[derive(Clone)]
struct AppState {
    pool: PgPool,
}
 
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = Config::load_from_env();
 
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
                // axum logs rejections from built-in extractors with the `axum::rejection`
                // target, at `TRACE` level. `axum::rejection=trace` enables showing those events
                format!(
                    "{}=debug,tower_http=debug,axum::rejection=trace",
                    env!("CARGO_CRATE_NAME")
                )
                .into()
            }),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();
 
    tracing::info!("Starting service...");
 
    tracing::info!("Connecting to Postgres...");
    let pool = PgPoolOptions::new()
        .max_connections(10)
        .min_connections(2)
        .idle_timeout(std::time::Duration::from_millis(30_000))
        .acquire_timeout(std::time::Duration::from_millis(30_000))
        .max_lifetime(Some(std::time::Duration::from_millis(1_800_000)))
        .connect(&format!(
            "postgres://{}:{}@{}:{}/{}",
            config.pg_user,
            config.pg_password,
            config.pg_host,
            config.pg_port,
            config.pg_database
        ))
        .await?;
 
    let state = AppState { pool };
 
    let app = Router::new()
        .layer(
            TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
                // Log the matched route's path (with placeholders not filled in).
                // Use request.uri() or OriginalUri if you want the real path.
                let matched_path = request
                    .extensions()
                    .get::<MatchedPath>()
                    .map(MatchedPath::as_str);
 
                info_span!(
                    "http_request",
                    method = ?request.method(),
                    matched_path,
                    some_other_field = tracing::field::Empty,
                )
            }),
        )
        .route("/healthcheck", get(|| async {
            axum::Json(serde_json::json!({"status": "ok"}))
        }))
        .route("/customer", post(routes::post_customer))
        .route(
            "/purchase",
            get(routes::get_purchases).post(routes::post_purchase),
        )
        .with_state(state);
 
    let port = 9050;
    tracing::info!("Starting local development server on port {}.", port);
    let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
 
    tracing::info!("Server started.");
    axum::serve(listener, app)
        .await
        .map_err(anyhow::Error::from)
}
 
#[derive(Debug, sqlx::FromRow)]
struct CustomerRecord {
    id: i32,
    name: String,
}
 
#[derive(Debug, sqlx::FromRow)]
struct SaleRecord {
    id: i32,
    transaction_date: OffsetDateTime,
}
 
#[derive(Debug, sqlx::FromRow)]
struct PurchaseRecord {
    sale_id: i32,
    product_id: i32,
    product_name: String,
    product_price_100x: i32,
    quantity: i32,
    transaction_date: OffsetDateTime,
}
 
mod db {
 
    pub mod store_dao {
        use sqlx::{Executor, Postgres};
        use crate::{CustomerRecord, PurchaseRecord, SaleRecord};
 
        pub async fn insert_customer<'e>(
            executor: impl Executor<'e, Database=Postgres>,
            name: String,
        ) -> anyhow::Result<CustomerRecord> {
            let customer: CustomerRecord =
                sqlx::query_as("INSERT INTO customer(name) VALUES($1) RETURNING id, name")
                    .bind(name)
                    .fetch_one(executor)
                    .await?;
 
            Ok(customer)
        }
 
        pub async fn insert_sale<'e>(
            executor: impl Executor<'e, Database=Postgres>,
            customer_id: i32,
        ) -> anyhow::Result<SaleRecord> {
            let sale: SaleRecord = sqlx::query_as(
                "INSERT INTO sale(customer_id) VALUES($1) RETURNING id, transaction_date",
            )
                .bind(customer_id)
                .fetch_one(executor)
                .await?;
 
            Ok(sale)
        }
 
        pub async fn insert_purchase<'e>(
            executor: impl Executor<'e, Database=Postgres>,
            sale_id: i32,
            product_id: i32,
            quantity: i32,
        ) -> anyhow::Result<()> {
            let result = sqlx::query(
                "INSERT INTO product_sale(sale_id, product_id, quantity) VALUES($1, $2, $3)",
            )
                .bind(sale_id)
                .bind(product_id)
                .bind(quantity)
                .execute(executor)
                .await?;
 
            if result.rows_affected() == 1 {
                Ok(())
            } else {
                Err(anyhow::anyhow!("Failed to insert purchase"))
            }
        }
 
        pub async fn list_purchases<'e>(
            executor: impl Executor<'e, Database=Postgres>,
            customer_id: i32,
        ) -> anyhow::Result<Vec<PurchaseRecord>> {
            let purchases = sqlx::query_as(
                r#"
            SELECT product_sale.sale_id  AS sale_id,
                   product.id            AS product_id,
                   product.name          AS product_name,
                   product.price_100x    AS product_price_100x,
                   product_sale.quantity AS quantity,
                   sale.transaction_date AS transaction_date
                FROM product
                     JOIN product_sale ON product.id = product_sale.product_id
                     JOIN sale ON product_sale.sale_id = sale.id
            WHERE sale.customer_id = $1
        "#,
            )
                .bind(customer_id)
                .fetch_all(executor)
                .await?;
 
            Ok(purchases)
        }
    }
}
 
mod routes {
    // Imports omitted for brevity
 
    #[derive(serde::Serialize, serde::Deserialize)]
    #[serde(rename_all = "camelCase")]
    pub struct CreateCustomerDTO {
        name: String,
    }
 
    #[derive(serde::Serialize, serde::Deserialize)]
    #[serde(rename_all = "camelCase")]
    pub struct ProductDTO {
        id: i32,
        quantity: i32,
    }
 
    #[derive(serde::Serialize, serde::Deserialize)]
    #[serde(rename_all = "camelCase")]
    pub struct CreatePurchaseDTO {
        products: Vec<ProductDTO>,
    }
 
    #[derive(serde::Serialize, serde::Deserialize)]
    #[serde(rename_all = "camelCase")]
    pub struct PurchaseDTO {
        sale_id: i32,
        product_name: String,
        price100x: i32,
        count: i32,
        #[serde(with = "crate::serde_iso8601::time")]
        date: OffsetDateTime,
    }
 
    pub async fn post_customer(
        cookie_jar: CookieJar,
        State(state): State<AppState>,
        Json(body): Json<CreateCustomerDTO>,
    ) -> Result<(CookieJar, StatusCode), StatusCode> {
        let customer = db::store_dao::insert_customer(&state.pool, body.name)
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
 
        let mut cookie = Cookie::new(crate::SESSION_COOKIE_NAME, customer.id.to_string());
 
        cookie.set_path("/");
        cookie.set_expires(OffsetDateTime::now_utc().add(time::Duration::hours(2)));
 
        Ok((cookie_jar.add(cookie), StatusCode::OK))
    }
 
    pub async fn post_purchase(
        cookie_jar: CookieJar,
        State(state): State<AppState>,
        Json(body): Json<CreatePurchaseDTO>,
    ) -> Result<(), StatusCode> {
        let customer_id = cookie_jar
            .get(crate::SESSION_COOKIE_NAME)
            .map(|cookie| {
                cookie
                    .value()
                    .parse::<i32>()
                    .expect("Failed to parse session token")
            })
            .ok_or(StatusCode::UNAUTHORIZED)?;
 
        let mut tx = state.pool.begin().await.map_err(|e| {
            tracing::error!("Failed to start transaction: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
 
        let sale = db::store_dao::insert_sale(&mut *tx, customer_id)
            .await
            .map_err(|e| {
                tracing::error!("Failed to insert sale: {}", e);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;
 
        for product in body.products {
            db::store_dao::insert_purchase(&mut *tx, sale.id, product.id, product.quantity)
                .await
                .map_err(|e| {
                    tracing::error!("Failed to insert purchase: {}", e);
                    StatusCode::INTERNAL_SERVER_ERROR
                })?;
        }
 
        tx.commit().await.map_err(|e| {
            tracing::error!("Failed to commit transaction: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
 
        Ok(())
    }
 
    pub async fn get_purchases(
        cookie_jar: CookieJar,
        State(state): State<AppState>,
    ) -> Result<Json<Vec<PurchaseDTO>>, StatusCode> {
        let customer_id = cookie_jar
            .get(crate::SESSION_COOKIE_NAME)
            .map(|cookie| {
                cookie
                    .value()
                    .parse::<i32>()
                    .expect("Failed to parse session token")
            })
            .ok_or(StatusCode::UNAUTHORIZED)?;
 
        let purchases = db::store_dao::list_purchases(&state.pool, customer_id)
            .await
            .map_err(|e| {
                tracing::info!("Failed to list purchases: {}", e);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;
 
        Ok(Json(
            purchases
                .into_iter()
                .map(|purchase| PurchaseDTO {
                    sale_id: purchase.sale_id,
                    product_name: purchase.product_name,
                    price100x: purchase.product_price_100x,
                    count: purchase.quantity,
                    date: purchase.transaction_date,
                })
                .collect(),
        ))
    }
}
 
pub mod serde_iso8601 {
    use ::time::format_description::well_known::{iso8601, Iso8601};
 
    const CONFIG: iso8601::EncodedConfig = iso8601::Config::DEFAULT
        .set_year_is_six_digits(false)
        .encode();
    const FORMAT: Iso8601<CONFIG> = Iso8601::<CONFIG>;
    ::time::serde::format_description!(time_format, OffsetDateTime, FORMAT);
 
    pub mod time {
        pub use super::time_format::*;
 
        pub mod option {
            pub use super::super::time_format::option::*;
        }
    }
}

Please forgive me if I've not followed proper coding standards for Rust, but I think this should hold up for a decent performance test at least.

What does the serde_iso8601 module do? 🤔

I noticed weird formatting of the ISO-8601 date-times when I was testing the APIs (I prefer returning human readable ISO-8601 date-times in my APIs instead of millis since epoch).

This is what it looks like with the default time::serde::iso8601 serializer:

#[derive(serde::Serialize, serde::Deserialize)]
struct TimeExample {
    #[serde(with = "time::serde::iso8601")]
    time: OffsetDateTime
}
 
/*
Outputs:
{
  "time": "+002025-07-12T14:37:21.975965100Z"
}
*/

See the +00 at the very start of the date-time string? This was quite unexpected to me. There's some discussion on the topic in this PR if you're curious. The solution is to create and use a serializer that's configured not to express years with six digits, which is why we need the serde_iso8601 module you see at the end of the Rust Web Service code block.

In case you're here because you have searched for this particular issue I've left a snippet for you below: 😉

#[derive(serde::Serialize, serde::Deserialize)]
struct TimeExample {
    #[serde(with = "crate::serde_iso8601::time")]
    time: OffsetDateTime
}
 
pub mod serde_iso8601 {
    use ::time::format_description::well_known::{iso8601, Iso8601};
 
    const CONFIG: iso8601::EncodedConfig = iso8601::Config::DEFAULT
        .set_year_is_six_digits(false)
        .encode();
    const FORMAT: Iso8601<CONFIG> = Iso8601::<CONFIG>;
    ::time::serde::format_description!(time_format, OffsetDateTime, FORMAT);
 
    pub mod time {
        pub use super::time_format::*;
 
        pub mod option {
            pub use super::super::time_format::option::*;
        }
    }
}
 
/*
Outputs:
{
  "time": "2025-07-12T14:52:35.312996400Z"
}
*/

Let's move on to the Dockerfile so that we can automatically build and run Rust as well.

Rust Dockerfile

We're using the same Linux distro (Debian) as for the Kotlin service but it's a bit neater than the Kotlin Dockerfile because I chose to build from a prepared Rust base image.

FROM rust:1.88.0-slim-bookworm AS build
 
WORKDIR /build
 
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        pkg-config \
        libssl-dev && \
    rm -rf /var/lib/apt/lists/*
 
COPY Cargo.toml Cargo.lock ./
COPY src/ ./src/
 
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    cargo build --release && \
    mv /build/target/release/rust-service /usr/local/bin/rust-service
 
FROM debian:bookworm-slim
 
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        ca-certificates \
        sysstat \
        curl && \
    rm -rf /var/lib/apt/lists/* && \
    apt-get clean
 
RUN useradd --create-home --shell /bin/bash appuser
 
WORKDIR /home/appuser
 
COPY --from=build --chown=appuser:appuser /usr/local/bin/rust-service /home/appuser/rust-service
 
USER appuser
WORKDIR /home/appuser
 
EXPOSE 9050
 
CMD ["/bin/bash", "-c", "./rust-service & pidstat -u -r -p $! --human 1 > process_metrics.txt"]

I'd like to mention that I learned quite a neat little bash trick while writing this. Notice the $! in the command above? It retrieves the pid (process id) of the last executed command which (I think) makes the above command quite nice and tidy.

Why curl? 🤔

If you have a good eye for detail (maybe you're a top-notch PR reviewer) you've probably noticed that we're installing curl in the containers, and it's not immediately obvious why. I would once again like to go out on a tangent, beginning with the embarrassing confession that I forgot to add a healthcheck endpoint to the services without really noticing at first. While running the load tests I noticed that Locust was reporting some transient failures when testing the Kotlin application which later stabilized. The Rust service didn't have the same problem. As it turns out, the Rust service booted up so quickly that it didn't really matter if I added a healthcheck step in the docker-compose.yml file that waits for the service to become available before starting the load tests. It did matter for the JVM application though. We now have a healthcheck step in the docker-compose.yml file that uses curl (in the container) to check whether the service is healthy before spinning up the load test container.

healthcheck:
  test: [ "CMD-SHELL", "curl -f http://localhost:9050/healthcheck || exit 1" ]

All this to say I'm impressed with the blazingly fast startup time of the Rust application. 🙌🦀


Locust

🦗 Locust is a beautifully simple load testing tool. You write Python code to define the actions a user can perform. There is a bunch of available configuration options depending on how you want to test your application. We're using the most basic features because we only want to model a customer sign in and (very) frequent product purchases.

from locust import HttpUser, task, constant
 
class QuickstartUser(HttpUser):
    wait_time = constant(1)
 
    @task
    def purchase(self):
        self.client.post("/purchase", json={
            "products": [
                {
                    "id": 1,
                    "quantity": 3
                },
                {
                    "id": 4,
                    "quantity": 2
                },
                {
                    "id": 3,
                    "quantity": 1
                }
            ]
        })
 
    def on_start(self):
        self.client.post("/customer", json={
            "name": "John Doe"
        })

When a user spawns it immediately starts purchasing the same products over and over every second. You can modify this file if you want more interesting user behavior.

Docker Compose

To enable quick iterations and a well defined setup I've fabricated a docker-compose.yml file that sets up the Locust, PostgreSQL and the Kotlin/Rust service. Let's have a look.

services:
  kotlin:
    profiles:
      - kotlin
    build: ./kotlin-service
    cpus: 2.0
    mem_limit: 512m
    environment:
      - PG_HOST=database
      - ENABLE_JFR=true # Toggle to enable flight recording for more detailed JVM metrics
    ports:
      - "9050:9050"
    networks:
      common:
        aliases:
          - kotlin
    depends_on:
      database:
        condition: service_healthy
    healthcheck:
      test: [ "CMD-SHELL", "curl -f http://localhost:9050/healthcheck || exit 1" ]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
 
  rust:
    profiles:
      - rust
    build: ./rust-service
    cpus: 2.0
    mem_limit: 512m
    environment:
      - PG_HOST=database
    ports:
      - "9050:9050"
    networks:
      common:
        aliases:
          - rust
    depends_on:
      database:
        condition: service_healthy
    healthcheck:
      test: [ "CMD-SHELL", "curl -f http://localhost:9050/healthcheck || exit 1" ]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
 
  database:
    image: "postgres:latest"
    environment:
      - POSTGRES_PASSWORD=password
      - PGUSER=postgres
    ports:
      - "5432:5432"
    networks:
      common:
        aliases:
          - database
    volumes:
      - ./infra/db/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready -U postgres -d postgres && psql -U postgres -d postgres -c 'SELECT 1;'" ]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
 
  locust-kotlin:
    profiles:
      - kotlin
    build: ./infra/load-test
    ports:
      - "8089:8089"
    networks:
      common:
        aliases:
          - locust
    depends_on:
      kotlin:
        condition: service_healthy
 
  locust-rust:
    profiles:
      - rust
    build: ./infra/load-test
    ports:
      - "8089:8089"
    networks:
      common:
        aliases:
          - locust
    depends_on:
      rust:
        condition: service_healthy
 
networks:
  common:

The above declaration might look a little busy, but it allows us to spin up a database, web service and locust with a single command:

docker-compose --profile "$SERVICE_NAME" up --build -d

The docker-compose command bootstraps everything (including compiling the projects in a Docker container) so that we can begin our load tests. If you're following along you can navigate to the Locust UI http://localhost:8089/ and play with different settings. It's quite fun to play with the parameters to see how far the limit goes. For more advanced tests I recommend installing Locust on multiple machines simultaneously hammering a service in a distributed fashion (if you use a cloud provider you might have to ask for permission before doing this, be careful).

Why is the Locust service declared twice? 🤔

We use profiles to decide which applications to run. For example, you can specify a profile in the Docker Compose service declaration so that the service is only run if that profile is enabled. We want to test one service at a time, but we have both Kotlin and Rust declared in the same docker-compose file so we use profiles to selectively launch one or the other. However, profiles are quite limited. You can't specify an environment variable based on the profile you've set so we can't modify the locust configuration and, for example, change the host of the application. Even more importantly, if you remember the health checks we installed before, we can't specify the service the locust service depends on because those are different depending on the profile. The easiest solution is to duplicate the declaration. 🤷‍♂️

Final Automation

It wouldn't be very convenient to run these tests with various parameters if we had to copy the results from the containers and from the Locust UI every time each test is finished. For this reason there's a cli.sh script in the root directory which:

  1. Spins everything up with docker-compose.
  2. Waits for Locust to be ready and then automatically starts the load test.
  3. Copies and downloads the results.
  4. Performs some basic data wrangling to convert the pidstat output file into two CSV files (memory and CPU usage).
  5. Stores the results in <projectRoot>/benchmarks/<date>/<serviceName>

Here's the script:

#!/usr/bin/env bash
 
set -o errtrace -o errexit -o pipefail -o nounset
 
function print_usage {
  echo "Usage: $0 <run_config> [--user-count COUNT] [--spawn-rate RATE] [--run-time TIME]"
  echo "  run_config: kotlin, rust, or all"
  echo "  --user-count: Number of users (default: 100)"
  echo "  --spawn-rate: Spawn rate (default: 10)"
  echo "  --run-time: Run time (e.g. 5m 1h, default: 1m)"
}
 
USER_COUNT="100"
SPAWN_RATE="10"
RUN_TIME="1m"
 
if [[ $# -eq 0 ]]; then
  echo "No run config provided, expected one of (kotlin|rust|all)."
  print_usage
  exit 1
fi
 
RUN_CONFIG=$1
shift
 
if ! [[ "$RUN_CONFIG" =~ ^(rust|kotlin|all)$ ]]; then
  echo "Invalid service name. Valid options are: all, rust, kotlin"
  exit 1
fi
 
while [[ $# -gt 0 ]]; do
  case $1 in
    --user-count)
      USER_COUNT="$2"
      shift 2
      ;;
    --spawn-rate)
      SPAWN_RATE="$2"
      shift 2
      ;;
    --run-time)
      RUN_TIME="$2"
      shift 2
      ;;
    *)
      echo "Unknown option: $1"
      print_usage
      exit 1
      ;;
  esac
done
 
function run_benchmark {
  BASE_OUT_DIR=$1
  SERVICE_NAME=$2
  echo "Running benchmark for $SERVICE_NAME"
 
  # Prepare output directory
  BENCH_OUT_DIR="$BASE_OUT_DIR/$SERVICE_NAME"
  mkdir -p "$BENCH_OUT_DIR" || {
    echo "Failed to create output directory: $BENCH_OUT_DIR"
    exit 1
  }
 
  trap 'docker-compose --profile "$SERVICE_NAME" down' RETURN
 
  if ! docker-compose --profile "$SERVICE_NAME" up --build -d; then
    echo "Failed to start the service. Please check the logs for more details."
    exit 1
  fi
 
  LOCUST_URL="http://localhost:8089"
 
  while [[ "$(curl -s "$LOCUST_URL/stats/requests" | jq -r '.state')" != "ready" ]]; do
    echo "Waiting for Locust to be ready..."
    sleep 1
  done
 
  curl -X POST "$LOCUST_URL/swarm" \
    --form "user_count=$USER_COUNT" \
    --form "spawn_rate=$SPAWN_RATE" \
    --form "host=http://$SERVICE_NAME:9050" \
    --form "run_time=$RUN_TIME"
 
  echo "Load test running on $LOCUST_URL"
  echo "Waiting for Locust to finish..."
  while [[ "$(curl -s "$LOCUST_URL/stats/requests" | jq -r '.state')" != "stopped" ]]; do
    sleep 10
  done
 
  echo "Load test completed. Fetching results..."
 
  CONTAINER_ID=$(docker-compose ps -q "$SERVICE_NAME")
 
  echo "Copying Memory and CPU metrics from container..."
  docker cp "$CONTAINER_ID":/home/appuser/process_metrics.txt "$BENCH_OUT_DIR/raw_process_metrics.txt" || {
    echo "Failed to copy process metrics from container."
    exit 1
  }
 
  # If 'ENABLE_JFR' is enabled, we want to copy the JFR file. It's easier to just try to copy it and ignore it if absent.
  if [[ "$SERVICE_NAME" == "kotlin" ]]; then
 
    # The JFR file is not available until the process is terminated so we must first kill the process and then copy the file.
    if docker exec -it "$CONTAINER_ID" bash -c "ps -x | grep 'java' | head -2 | tail -1 | awk '{print \$1}' | xargs kill"; then
      docker cp "$CONTAINER_ID":/home/appuser/flight_recording.jfr "$BENCH_OUT_DIR/flight_recording.jfr" || {
        echo "JFR file not found on host, assuming it's disabled."
      }
    fi
  fi
 
  echo "Converting Memory and CPU metrics to separate CSV files..."
  python3 metric_parser.py "$BENCH_OUT_DIR/raw_process_metrics.txt" || {
    echo "Failed to convert Memory and CPU metrics to CSV files."
    exit 1
  }
 
  echo "Copying Locust results..."
  curl "$LOCUST_URL/stats/report?theme=dark" -o "$BENCH_OUT_DIR/locust_report.html" || {
    echo "Failed to fetch Locust report."
    exit 1
  }
 
  curl "$LOCUST_URL/stats/requests/csv" -o "$BENCH_OUT_DIR/locust_requests.csv" || {
    echo "Failed to fetch Locust requests CSV."
    exit 1
  }
 
  echo "Results stored in $BENCH_OUT_DIR"
}
 
BASE_OUT_DIR="benchmarks/$(date +%Y-%m-%d_%H-%M-%S)"
 
if [[ "$RUN_CONFIG" == "all" ]]; then
  echo "Running all benchmarks..."
  for SERVICE in rust kotlin; do
    run_benchmark "$BASE_OUT_DIR" "$SERVICE"
  done
else
  run_benchmark "$BASE_OUT_DIR" "$RUN_CONFIG"
fi
 
echo -e "\e[1;32mBenchmark completed successfully.\e[0m"

I've tried making it as simple as possible to just run:

cli.sh all

Without having to install anything on your machine except jq, curl, python and Docker. The first three are usually pre-installed on most Linux systems. Python is needed to convert the pidstat output to CSV files for visualization using the metric_parser.py script in the project root.

The script runs the tests via docker-compose.yml which also builds everything in Docker containers so you don't have to worry about the script installing any dependencies on your machine.

If you want to see each command that's executed by the script as it's running you can execute it with the execution trace option:

bash -x cli.sh all

The scrips accepts the following optional parameters:


Benchmark #1 - 500 users

Alright, we're ready to run some tests. Throughout all benchmarks Rust will be compiled with default --release option. We aren't adding any JVM options when running the Kotlin application either.

I did actually test tweaking the Rust and Java options somewhat, but I didn't see a big enough difference to include them here. However, it's entirely possible to bring down the Java memory usage considerably by setting the -Xmx option to a lower value. The amount of permutations that can be formed by tweaking all of these flags and options is quite large, so I'll leave it as an exercise to the curious reader.

Host specs:

I've configured docker-compose to allocate 2 CPUs and 512MB of memory for the services to simulate the minimum tier of popular cloud provider offerings.

Let's run the cli.sh script with the following parameters. Grab a coffee or tea and chill out while the test runs if you're following along ☕

./cli.sh all --user-count 500 --spawn-rate 10 --run-time 5m

If you're following along you can quickly view the results in the analysis.ipynb notebook. Just run all the cells so that the charts pop up. The notebook is written in Kotlin, so you might want to use IntelliJ IDEA for the smoothest experience.

Now, let's have a look at the results.

Request Latency

Aggregated Request Latency Metrics
Request latency percentiles

Unsurprisingly the request latencies are mostly the same except in the 99th percentile and above which is due to the cold start of the Kotlin application code (most likely the very first couple of requests). Everything else is almost exactly the same which is expected.

Why is this expected? We're just forwarding all calls to the PostgreSQL database so there's nothing computationally expensive going on here. Most of the time is spent doing:

The 99.99th percentile request(s) in Kotlin is most likely due to the cold start of the application in the JVM. Classes needs to be loaded, initial connections to the database must be made, perhaps some JIT2 related features spring into action and a bunch of other things. Typically in real applications the healthcheck endpoint issues a database requests that checks the database connection with the added benefit of initializing the connection pool to the database before accepting any requests. We could improve upon the Kotlin (and Rust) service to include this functionality if request latency is an issue.

So far so good. Considering servers typically run for extended periods of time I'm not too worried about the very first request being slow unless there's an SLA promising faster response times. If we were developing this application for serverless functions (AWS Lambda for example) the latency would be a dealbreaker for Kotlin (on the JVM)3. There are methods of avoiding long cold starts for AWS Lambda using SnapStart but it does have some quirks.

CPU Usage

CPU Usage
CPU usage by service (100% = 1 core)

Now this is the interesting part. We can see the CPU usage shoot up at the very start of the Kotlin application while the CPU usage of the Rust application is increasing much more evenly. The JVM does a bunch of things at the very start of the application (which we covered briefly in the previous section) resulting in these CPU spikes. The smaller spikes that occur regularly throughout the applications lifetime is most likely garbage collection events or JIT compilation.

By simply eyeballing the chart we can see that the baseline memory usage appears to converge around 42% for Kotlin and 23% for Rust, making the Kotlin server about 82% more CPU intensive compared to the Rust server in this scenario.

CPU time is the most valuable resource today from my experience. RAM is comparably cheap and available while CPU time gets throttled and is usually virtualized and shared among other tenants. If you've been unsure of whether it's worth investing in Rust before, maybe this is convincing enough if you're looking into reducing costs.

Memory Usage

Memory Usage
RSS (Resident Set Size) Memory usage by service

Yet again I'll eyeball the graph to conjure an estimate. We can see that the Kotlin application stabilizes at around 214MB of memory usage while the Rust application appears to be fine running on fumes, clocking in at about 18.4MB of memory for 500 active users. Meaning that the Kotlin application uses about 1163% more memory than the Rust application. That's... quite incredible, but not too surprising.

Only about 47MB of the memory belongs to the Committed Heap Space memory of which only 20.4MB is actually in use by the application. The JVM is a heavy machine that uses a lot of baseline memory, meaning that, if we doubled the amount of active users we won't double the memory usage. We'll make that comparison in the next test.

Either way, it's quite clear that Rust is vastly more memory efficient than Kotlin in this case (and, to be honest, probably all other cases too). However, memory is quite cheap, and it might not matter for your use case, but it's worth keeping in mind.

Let's move on to the next test.


Benchmark #2 - 1,000 users

Let's double the amount of users and keep all other configurations the same.

./cli.sh all --user-count 1000 --spawn-rate 20 --run-time 5m

Notice that I've also doubled the spawn rate so that we keep the acceleration of users uniform.

We now have 1k users spawning at a rate of 20 per second. Let's have a look at the results.

Request Latency

Aggregated Request Latency Metrics
Request latency percentiles

The latencies are more even this time, probably because this test issues more requests overall which evens out the distribution across all percentiles, pushing the requests affected by the JVM cold start to the higher percentiles.

CPU Usage

CPU Usage
CPU usage by service (100% = 1 core)

Looks like Kotlin stabilizes around 80% and Rust around 42% CPU usage, increasing the gap compared to the previous test. To summarize, Kotlin increased CPU consumption by ~90% and Rust increased by ~82% when doubling the amount of active users from 500 to 1.000 users, suggesting that Rust is better at managing CPU resources when scaling up.

Memory Usage

Memory Usage
RSS (Resident Set Size) Memory usage by service

Now Kotlin stabilizes around 224MB and Rust at about 29.2MB. Notice that the Kotlin application didn't change much, from 214MB to 224MB, increasing memory usage by 10MB (roughly 4.6%). The JVM committed 59.2MB (+12.2MB) memory out of which 28.3MB (+7.9MB) was in use.

Rust increased memory usage from 18.4MB to 29.2MB, an increase of 10.8MB (roughly 58.6%) but still uses 87% less memory than the Kotlin application.

The Kotlin application only increased memory usage by 4.6% when doubling the load, showing that the baseline memory usage of the JVM is quite high.

If you're running multiple JVMs on the same machine it might be worth looking into enabling -Xshare:auto. Enabling sharing of class data can help keep the overall memory footprint down by allowing multiple JVMs to share class data with each other, reducing duplication. Additionally, if you're allocating and keeping a lot of string objects around, you can experiment with the -XX:+UseStringDeduplication flag as well. The garbage collector will identify strings that are identical and internally re-reference the pointers to the same memory address. This is different from the statically allocated strings that end up in the String Pool.


Benchmark #3 - Alpine 1,000 users

Request Latency

Aggregated Request Latency Metrics
Request latency percentiles

Request latencies are pretty much identical, no change here.

CPU Usage

CPU Usage
CPU usage by service (100% = 1 core)

CPU Usage remains mostly the same, no real difference to make note of.

Memory Usage

Memory Usage
RSS (Resident Set Size) Memory usage by service

Here's the interesting part. Notice how both Kotlin and Rust uses less memory now that we're running in an Alpine Linux container? Kotlin evens out at 187.9MB and Rust at about 25.2MB which means that Kotlin has reduced memory usage by 36.1MB and Rust reduced memory usage by 4MB. But why is that? We've only changed the base image from Debian to Alpine, why does the applications themselves use less memory?

Well, Alpine does not support glibc which means that, for Rust, we're compiling towards the musl target instead of glibc. Similarly, it doesn't matter that we compiled Kotlin in the Alpine container, but rather that we're running the application with a JVM compiled with musl instead of glibc. The eclipse-temurin:21-alpine-3.22 base image downloads a JVM that's compiled with musl instead of the standard glibc.

So what's musl and glibc? glibc is the more mature libc implementation and is commonly available on most Unix systems. However, musl is an a modern alternative that is more lightweight than glibc. This is the reason why simply switching the container base image can give us even lower memory usage.

I thought it was an interesting addition to mention the musl target, both for the JVM and Rust to display some of their differences. Choose whatever works best for your use case, but be mindful of the differences. Musl used to have DNS issues which should since have been fixed, but keep in mind that there are many articles online that mention the pain of debugging DNS query issues in Kuberenetes clusters4, particularily with Alpine containers.


Conclusion

🦀 Should I all-in Rust?

I thought this was a fun exercise to compare a language that's designed to be extremely fast and efficient with a language/platform that's more focused on ergonomics. I'm not going to tell you that Rust is the only correct choice now and always, but I think it's important to bring these differences to light so that we can make decisions based on data. Feel free to clone and modify the code to adapt it to your use case. There's an accompanying notebook you can run for quick feedback loops.

Raw performance isn't everything. Choosing a technology depends a lot on the skills you already have available in your company, or on the job market. Rust is gaining traction incredibly quickly and great improvements are constantly being made to improve the ergonomics of the language, but I still feel a bit cornered by the many rules the compiler enforces on you when trying to quickly iterate on some new feature or functionality. Kotlin feels quicker when writing code but Rust is quicker running it, at least in my experience. I feel that this is especially true when writing tests, which I often have to shoehorn in, or trade static impls for dynamic dispatch to get some mocks working. I'm sure this too will improve in the future though, and I'm by no means a Rust expert so YMMV.

🌳 The Environmental Aspect

One aspect that I think is often overlooked, especially in corporations that have many thousands of different services running hundreds- to thousands of instances starting to think about efficiency doesn't just affect your bottom line, but also reduces the carbon footprint. It's even more important today considering the explosion of data processing that's happened since LLMs took over the scene.

Footnotes

  1. In Rust you annotate a struct with #[derive(serde::Serialize, serde::Deserialize)] which is a macro that generates the code needed to for (de)serialization during the macro expansion phase (before compilation). There's no need for runtime reflection. Super cool feature.

  2. I'm referring to the Just in Time (JIT) compilation that the JVM does on hot code paths. There's a lot of juicy information on OpenJDK if you want to read more.

  3. Kotlin/Native compiles to native code (with a garbage collector) similar to Golang. I've not investigated its performance as the ecosystem of libraries is quite limited because you can't tap into the Java ecosystem. However, I think their efforts are focused on cross platform mobile development with Compose Multiplatform which is really promising.

  4. The musl DNS issue discussed in multiple places theregister, martinheinz, Hacker News