Direct answer
Ktor does not have a documented automatic size-based switch from buffering to streaming. In the buffered path, call.receive<T>() reads the entire body before conversion, and an empty body with a declared Content-Type fails in the receive step with MissingRequestContent rather than inside your converter. Treat any roadmap claim about automatic switching as unverified for your Ktor version.
Confirmed facts vs proposed design
- Confirmed by the supplied research:
receive<T>() buffers the whole body; MissingRequestContent is framework-generated for a zero-byte body when a content type is declared; streaming requires call.receiveChannel() or call.receiveStream(). - Proposed pattern: an opt-in threshold policy. This is an implementation approach, not a claim that Ktor ships it.
Criteria for switching
| Condition | Recommended action |
|---|
Content-Length is present, exceeds the threshold, the media type has a registered streaming converter, and the route opted in | Use receiveChannel()/receiveStream() with an incremental parser |
Content-Length is below the threshold, or no streaming converter exists | Keep the buffered call.receive<T>() path |
Content-Length is absent or the body is chunked | Do not auto-switch; require an explicit streaming route or an always-stream configuration |
Content-Length: 0 with a declared Content-Type | MissingRequestContent remains; streaming cannot fix an empty body |
How to expose a threshold without breaking call.receive
Keep call.receive<T>() unchanged for existing code. Add a small policy plugin that only sets an attribute when the request is eligible for streaming. Assume Ktor 2.x/3.x server APIs, but verify the exact plugin and receive entry points for your version.
val StreamingRequest = AttributeKey<Boolean>("ktor.streaming.request")
val RequestStreamingPolicy = createApplicationPlugin(
name = "RequestStreamingPolicy",
createConfiguration = ::Config
) {
val threshold = pluginConfig.thresholdBytes
val allowed = pluginConfig.contentTypes.toSet()
on(Call) { call ->
val length = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
val type = call.request.contentType()?.withoutParameters()
if (length != null && length > threshold && type in allowed) {
call.attributes.put(StreamingRequest, true)
}
}
}
suspend inline fun <reified T> ApplicationCall.receiveMaybeStreaming(): T =
if (attributes.getOrNull(StreamingRequest) == true) {
// decode incrementally from receiveChannel()/receiveStream()
receiveStreaming<T>()
} else {
receive<T>()
}
Configure it additively, for example with requestStreamingThresholdBytes: 1048576 and streamingContentTypes: [application/json]. Small requests continue through the buffered path; only new routes call receiveMaybeStreaming<T>() or an explicit streaming handler.
Should Ktor provide this as a plugin?
A dedicated plugin is the right shape, but it should be opt-in and should not silently change call.receive<T>() semantics. Many converters and plugins assume a fully buffered value. If Ktor adds automatic switching, it should apply only when the length is known, a streaming converter is registered, and existing receive behavior remains intact.
Verification
Test four cases: a 500 KB JSON body below the threshold, a 2 MB body above it, a zero-byte body with Content-Type: application/json, and a chunked body without Content-Length. For example: curl -X POST -H 'Content-Type: application/json' --data-binary @large.json http://localhost:8080/upload. Confirm the small body still uses receive<T>(), the large body uses the streaming path, the empty body still raises MissingRequestContent, and chunked requests follow your intended fallback.
If your clients commonly send chunked bodies without Content-Length, say so; that changes the recommendation from threshold-based streaming to explicit streaming routes.