Ktor streaming response backpressure and memory exhaustion risks
0 reputation · 29 May 2023, 07:06 UTC
0 reputation · 29 May 2023, 07:06 UTC
When serving large datasets using call.respondStream or ByteReadChannel, the goal is to maintain a low memory footprint by avoiding loading the entire collection into memory. While Ktor provides primitives to stream data in chunks, the interaction between the data source and the client-side consumption rate remains complex.
Specifically, if a HTTP client consumes data at a rate slower than the server produces it, it is unclear if Ktor natively propagates backpressure signals back to the underlying data source (such as a database cursor or a reactive flow). Without a direct signal to pause the data generation, the internal buffers may grow, potentially leading to Out-Of-Memory errors under high load or slow network conditions.
How does Ktor handle backpressure from a slow HTTP client back to a suspending data-producing block? Is there a built-in mechanism to pause the execution of the stream when the ByteWriteChannel buffer is full?
31775 reputation · 29 May 2023, 12:39 UTC
When you use call.respondStream or write directly to a ByteWriteChannel, Ktor does not copy the entire payload into memory. Instead, the data is produced by a coroutine that writes to a channel whose internal buffer has a configurable size (default 8 KiB). The write operation is a suspending call: if the buffer is full, the coroutine that is producing data suspends until the channel’s consumer (the HTTP client) has drained enough space. This is Ktor’s built‑in backpressure mechanism.
GlobalScope or another detached scope, the write call will not suspend the original coroutine, and the buffer can grow unchecked.Thread.sleep(), synchronous JDBC calls) prevent the coroutine from suspending, so backpressure is effectively disabled.coroutineContext.isActive or rely on the suspending write to stop promptly.Write the stream inside the request coroutine:
call.respondStream {
for (i in 1..10000) {
writeChannel.write(i.toByte())
delay(10) // keep the producer suspending
}
}
Avoid GlobalScope.launch or other detached coroutines for data generation.
Do not use blocking calls inside the streaming lambda. If you must call a blocking API, wrap it with withContext(Dispatchers.IO) { … }.
Configure the buffer size if you need tighter control: application.conf can set ktor.application.sendBufferSize to 2 KiB, 16 KiB, etc. The backpressure threshold scales with this value.
Verify memory usage by running a throttled client (e.g., curl --limit-rate 1k) and monitoring the JVM heap. Stable, low memory indicates correct backpressure handling.
To tailor the recommendation, could you confirm whether the data source is launched inside the request coroutine or in a detached scope? This determines whether backpressure is already in effect.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.