skip to content
back to til

The JVM's memory floor, and why Railway bills for it

til

I picked Ktor because the backend could live in the same module as the client. That is the appeal of Kotlin Multiplatform: the server and apps can share application logic instead of implementing it separately for each platform. Then a Railway invoice for a handful of small services reminded me that an idle JVM still costs money when memory is billed by usage.

Memory beyond the heap

I had been thinking about memory as I would on a machine I already owned: how much heap, the memory used for application objects, does the app need under load? For one of my services, that was maybe 100MB. Railway measures container RSS, the memory held in RAM by the container. For a JVM process, that includes the heap and the memory used by the runtime itself. Loaded classes take up metaspace, and Ktor, Netty, kotlinx.serialization, Exposed, and a JDBC driver add up quickly. There is also a cache for code compiled by the just-in-time compiler (JIT), roughly a megabyte of stack for each thread Netty starts, and buffers outside the heap for network traffic.

One service sat around 300 to 400MB RSS while doing almost nothing. The heap was the smaller part of that. I first tried reducing the heap:

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50 -XX:+UseSerialGC -Xss512k"

That helped. SerialGC noticeably reduced the garbage collector's own memory use on a single-core container, but the runtime still needed a base amount of memory. I got to roughly 220MB. Cutting more would have meant giving up libraries I had chosen Ktor for.

The monthly cost

Railway prices memory per GB-month1, so an idle service adds to the bill. On a virtual private server (VPS), I would already have paid for that capacity. At $10 per GB-month, a service using 350MB costs about $3.50 a month even with no traffic. I had several, plus a couple of preview environments. The total was small, but it felt high for what the services did. Most of the memory I paid for was keeping the runtime alive.

Replacing two services

Two services were small enough to rewrite in an afternoon each. One went to Go:

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	http.ListenAndServe(":8080", mux)
}

The other mostly parses and forwards data. I rewrote it in Rust with axum:

#[tokio::main]
async fn main() {
    let app = Router::new().route("/healthz", get(|| async { StatusCode::OK }));
    let listener = TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Idle RSS fell to around 20 to 30MB for Go and under 15MB for Rust, roughly a tenth of the tuned JVM's memory use or less. Two other changes ended up mattering more to me than the memory savings. The container images shrank from a few hundred megabytes for a Java runtime and a JAR containing all dependencies to single-digit megabytes for a static Go binary on scratch or a Rust binary on distroless. Deploys became much quicker. The services were also ready right away, while Ktor took seconds to warm up. That made it practical to stop them when idle and start them when a request arrived.

What I gave up

The JVM's base memory use is a fixed cost. One large service on one large machine can spread that cost over enough work to beat both of these on throughput per dollar, with the JIT speeding up frequently run code over time. Paying that cost separately for several mostly idle containers made less sense for my services.

Moving off the JVM also meant losing the module shared with the client. The same types now exist in Kotlin for the apps and in Go or Rust for the server, and I have to update both whenever their structure changes. Kotlin Multiplatform was supposed to remove exactly that duplication. The extra work continues after the rewrite. These two services kept no state between requests and mostly passed data around, so they shared little code with the client. I think the savings were worth it. For services with substantial application logic, I would keep the shared code and combine several Ktor services into one JVM process. I should have considered that first.

Footnotes

  1. https://railway.com/pricing

comments