Ktor 3.6.0 Help

What's new in Ktor 3.6.0

Released: September 17, 2026

Ktor 3.6.0 delivers a range of improvements across server and client. Highlights of this feature release include:

Ktor Server

Additional type support for request parameters

Ktor 3.6.0 expands the set of types supported by default when converting request parameters to typed values.

The following types are now supported:

  • Byte

  • java.lang.Byte

  • UByte

  • UInt

  • UShort

  • ULong

  • Uuid

For example, you can retrieve a Uuid parameter directly inside a route handler through property delegation:

get { val uuid: Uuid by call.parameters }

Zstandard (zstd) and DEFLATE support for pre-compressed static files

Ktor can now serve pre-compressed static content in Zstandard (zstd) and DEFLATE formats.

To enable the new formats, use the CompressedFileType.ZSTD and CompressedFileType.DEFLATE enum constants with the preCompressed() function:

staticResources("staticResources", "public") { preCompressed( CompressedFileType.ZSTD, CompressedFileType.DEFLATE ) }

OpenAPI tag descriptions

You can now define descriptions for OpenAPI tags directly in the openAPI {} and swaggerUI {} configuration blocks:

swaggerUI("/swagger") { info = OpenApiInfo("Books API from routes", "1.0.0") tag( name = "Books", description = "Operations on books" ) }

The tag description is added to the top-level metadata of the generated OpenAPI document.

New ApplicationCall.respondHtmlPartial() function

The new .respondHtmlPartial() function replaces .respondHtmlFragment() for responding with partial HTML content.

It uses TagConsumer<Appendable> as the lambda receiver, which allows you to return unrestricted HTML content, such as table cells:

call.respondHtmlPartial(HttpStatusCode.Created) { td { +"Created!" } }

The deprecated .respondHtmlFragment() function uses FlowContent, which restricts the HTML elements that can be returned. It is now deprecated in favor of .respondHtmlPartial().

Netty

HTTP/3 support

The Netty server engine now includes experimental support for HTTP/3 over QUIC.

To enable HTTP/3, configure an SSL connector and call the enableHttp3() function in the Netty engine configuration:

embeddedServer(Netty, environment, { // SSL connector is required sslConnector( keyStore = keyStore, keyAlias = "server", keyStorePassword = { "changeit".toCharArray() }, privateKeyPassword = { "changeit".toCharArray() } ) { port = 8443 host = "0.0.0.0" } enableHttp3 { quicTokenHandler = HmacQuicTokenHandler() // Optional quicMaxIdleTimeout = 30.seconds quicInitialMaxData = 10_000_000 quicInitialMaxStreamDataBidirectionalLocal = 1_000_000 quicInitialMaxStreamDataBidirectionalRemote = 1_000_000 quicInitialMaxStreamsBidirectional = 100 udpSocketCount = 1 udpReceiveBufferSize = 0 udpSendBufferSize = 0 configureQuicServerCodec = { /* Optional low-level Netty tuning */ } } }) { /* Application */ }.start(wait = true)

You can also use the enableHttp3 {} block to configure QUIC-specific options such as connection timeouts, flow-control limits, and UDP socket settings.

Use h2c alongside HTTP/2 over TLS

The Netty server engine can now serve HTTP/2 over cleartext (h2c) and HTTP/2 over TLS on the same server.

This allows you to configure a cleartext connector and an SSL connector, then enable both HTTP/2 and h2c:

embeddedServer(Netty, configure = { connector { port = 8080 } sslConnector(...) { port = 8443 } enableHttp2 = true enableH2c = true }) { // ... }

The cleartext connector accepts h2c connections, while the SSL connector serves HTTP/2 over TLS.

Rate limiting with authenticated principals

The RateLimit plugin can now access authentication principals during request validation.

This allows you to nest the rateLimit() function inside authenticate() and use call.principal() in the requestKey() function to apply rate limits per authenticated user:

install(Authentication) { basic("auth") { validate { UserIdPrincipal(it.name) } } } install(RateLimit) { register(RateLimitName("per-user")) { rateLimiter(limit = 10, refillPeriod = 60.seconds) requestKey { call.principal<UserIdPrincipal>()?.name ?: "anonymous" } } } routing { authenticate("auth") { rateLimit(RateLimitName("per-user")) { get("/api") { call.respondText("OK") } } } }

You can also place rateLimit() outside authenticate() to apply rate limiting before authentication. Use this approach when the rate limit doesn't depend on an authenticated principal.

Type-safe authentication scheme API

Ktor 3.6.0 introduces an experimental type-safe authentication scheme API. Instead of installing a named provider and referring to it by string, you create a scheme value and pass it to the routes that need it. Inside a protected route, call.principal has the scheme's principal type and is guaranteed to be non-null:

data class User(val id: String, val email: String) val jwtAuth = jwt<User>("my-jwt") { verifier(jwkProvider, issuer) validate { credential -> val payload = credential.payload User( id = payload.subject, email = payload.getClaim("email").asString() ) } } routing { authenticateWith(jwtAuth) { get("/profile") { call.respondText(call.principal.email) } } }

The API also adds opt-in role checks, an anonymous fallback, typed session, and OAuth 2.0 support.

OpenID Connect plugin

Ktor 3.6.0 adds an experimental OpenID Connect plugin. Instead of configuring discovery, JWKS resolution, JWT validation, and OAuth callbacks separately, you register a provider using its issuer URL and get typed authentication schemes:

suspend fun Application.module() { val oidc = install(Oidc) val google = oidc.identityProvider("google") { issuer = "https://accounts.google.com" bearer { audience = setOf("my-api") } } routing { authenticateWith(google.jwtBearer) { get("/me") { call.respond(call.principal.userInfo) } } } }

The plugin supports both resource servers that validate incoming access tokens and browser login with sessions, logout, and token refresh. It implements the authorization code flow with PKCE, token introspection (RFC 7662), resource indicators (RFC 8707), and protected resource metadata (RFC 9728).

Nullable request bodies with ApplicationCall.receive()

Ktor now supports nullable type arguments with the ApplicationCall.receive() function.

The .receiveNullable() function is deprecated. Use .receive() with a nullable type when a request body can be null:

post("/") { val payload = call.receiveNullable<Payload?>() }
post("/") { val payload = call.receive<Payload?>() }

This makes the expected request contract explicit in the type:

  • receive<MyType>() requires a non-null value.

  • receive<MyType?>() accepts a value or null.

For example, an endpoint can use null to clear existing notification preferences:

@Serializable data class NotificationPreferences( val emailEnabled: Boolean, val pushEnabled: Boolean, ) put("/users/{userId}/notification-preferences") { val userId = call.parameters.getOrFail("userId") val preferences = call.receive<NotificationPreferences?>() if (preferences == null) { preferenceService.clear(userId) } else { preferenceService.update(userId, preferences) } call.respond(HttpStatusCode.NoContent) }

Non-nullable calls to .receive() continue to work as before. Response APIs are unaffected.

Ktor Client

Default client engines for multiplatform projects

Ktor 3.6.0 introduces the ktor-client-engine-defaults artifact, which provides a curated set of HTTP client engines for Kotlin Multiplatform projects.

Add the dependency to the commonMain source set:

kotlin { sourceSets { commonMain { dependencies { api("io.ktor:ktor-client-engine-defaults:3.6.0") } } } }

You can then create an HttpClient without specifying an engine:

val client = HttpClient()

For each target platform, Ktor uses the default engine provided by ktor-client-engine-defaults. If more than one engine is available, the client selects the engine with the highest priority. CIO has the lowest priority by default, so the client selects another available engine over CIO.

If your multiplatform project currently uses CIO across all supported targets, consider replacing the CIO dependency with ktor-client-engine-defaults. This lets Ktor provide a curated default engine for each platform while keeping engine selection out of your common source set.

You can still declare a specific client engine when you need engine-specific configuration or behavior.

WebRTC client support for JVM

The experimental WebRTC client now supports JVM desktop applications.

The JVM implementation uses webrtc-java native WebRTC bindings and provides support for peer connections, audio and video tracks, data channels, and connection statistics.

JVM support currently has several platform-specific limitations. For more information, see the WebRTC client documentation.

Multiplatform file storage for HTTP caching

The HttpCache plugin now supports multiplatform file storage.

Previously, the FileStorage() function was available only on the JVM and required a java.io.File. It now uses the kotlinx-io library, which allows you to configure persistent file-based caching on any supported platform using Path.

val client = HttpClient { install(HttpCache) { val cacheFile = Files.createDirectories(Paths.get("build/cache")).toFile() publicStorage(FileStorage(cacheFile)) } }
val client = HttpClient { install(HttpCache) { publicStorage(FileStorage(Path("build/cache"))) } }

This replaces the JVM-specific setup that creates a File before passing it to FileStorage().

Control Accept header merging in ContentNegotiation

You can now control how the client ContentNegotiation plugin merges registered content types with an existing Accept header.

By default, the ContentNegotiation plugin adds registered content types that aren't already represented in the request's Accept header.

If you set an Accept header explicitly and don't want the plugin to add registered content types, set the acceptHeaderMergeStrategy property to ContentTypeMergeStrategy.SkipIfPresent:

install(ContentNegotiation) { register(ContentType.Application.Json, noOpJsonConverter) acceptHeaderMergeStrategy = ContentTypeMergeStrategy.SkipIfPresent }

With SkipIfPresent, the plugin preserves an existing Accept header. If the request doesn't contain an Accept header, the plugin adds the registered content types as usual.

Asynchronous DNS resolution in the CIO client engine

This release adds support for custom DNS resolution in the CIO client engine.

On the JVM, the CIO engine previously relied on system DNS resolution, which can block threads. You can now override the DNS resolution using the dnsResolver property in the CIO engine configuration.

For example, use the CioDnsResolver() function to resolve hostnames asynchronously through a specific DNS server and configure a timeout:

HttpClient(CIO) { engine { dnsResolver = CioDnsResolver( server = "1.1.1.1", timeout = 3.seconds ) } }

Override fetch() in the JavaScript client engine

You can now override the global fetch() function used by the JavaScript client engine.

To provide a custom implementation, set the fetch property in the Js engine configuration:

val client = HttpClient(Js) { engine { fetch = { url, init -> Promise.reject(IllegalStateException("Networking not available")) } } }

This is useful when integrating with JavaScript libraries that provide their own fetch() wrapper, such as AWS WAF. If you don't configure fetch, the engine continues to use the global fetch() function.

Shared

You can now use the parseClientCookies() function to parse Cookie headers that contain multiple cookies with the same name.

Unlike the parseClientCookiesHeader() function, which returns a Map<String, String> and keeps only the last value for duplicate names, the parseClientCookies() function returns a List<Pair<String, String>> and preserves duplicate cookie entries:

val header = "name=value1; name=value2" val cookies = parseClientCookies(header) // [("name", "value1"), ("name", "value2")] val cookieMap = parseClientCookiesHeader(header) // {"name"="value2"}

Use parseClientCookies() when duplicate cookie names need to be preserved.

18 September 2026