Persistent HTTP caching for Ktor HttpClient with disk-backed storage, configurable TTL and max size, LRU eviction, Vary-header aware variants, and optional custom cache-directory provider.
A Kotlin Multiplatform library that adds persistent HTTP caching to Ktor HttpClient. Responses are stored on disk using Okio, with configurable size limits, TTL, and platform-appropriate cache directories.
CacheStorage) with Okio persistence — the same backing store installPersistentCache uses when enabled is true.Vary headers so different variants (e.g. by
Accept-Language) are cached separately.| Platform | Cache directory |
|---|---|
| Android | Application cache dir (context.cacheDir) |
| iOS | App caches directory (NSCachesDirectory in the sandbox) |
| JVM | java.io.tmpdir/ktor-cache |
ktor-client-core 3.4.0+) and an engine (CIO, OkHttp, etc.) for your
targetsAdd the dependency to your shared or platform source sets.
Kotlin DSL (Gradle):
repositories {
mavenCentral()
// For snapshots:
// maven("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}
dependencies {
commonMain.dependencies {
implementation("io.github.santimattius:ktor-persistent-cache:1.0.0-SNAPSHOT")
}
// Also add a Ktor engine for each target, e.g.:
// implementation("io.ktor:ktor-client-okhttp") // Android
// implementation("io.ktor:ktor-client-cio") // iOS / JVM
}Version catalog (e.g. libs.versions.toml):
[versions]
ktorPersistentCache = "1.0.0-SNAPSHOT"
[libraries]
ktor-persistent-cache = { group = "io.github.santimattius", name = "ktor-persistent-cache", version.ref = "ktorPersistentCache" }Then:
implementation(libs.ktor.persistent.cache)The library needs the application context to resolve the cache directory. The recommended way is App Startup:
Merge the library’s manifest
The shared (or Android) module that depends on ktor-persistent-cache should merge the
library’s AndroidManifest so that the App Startup InitializationProvider and
ContextInitializer are registered.
No extra code
If the manifest is merged, ContextInitializer runs at app startup and injects the application
context. getCacheDirectoryProvider()
will then use it automatically.
If you don’t use the library’s manifest (e.g. you use a different DI or startup path), you must call once at app startup with the application context (not an Activity context):
import io.github.santimattius.persistent.cache.startup.injectContext
// e.g. in Application.onCreate()
injectContext(applicationContext)injectContext is a public API in io.github.santimattius.persistent.cache.startup. It throws
IllegalArgumentException if you pass a context that can leak memory (for example an Activity).
No setup. The library uses the default app caches directory.
No setup. The library uses a subdirectory of the JVM temp directory.
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.github.santimattius.persistent.cache.*
val client = HttpClient(CIO) {
installPersistentCache(
CacheConfig(
enabled = true,
cacheDirectory = "http_cache",
maxCacheSize = 10L * 1024 * 1024, // 10 MB
cacheTtl = 60 * 60 * 1000, // 1 hour
isShared = true,
isPublic = false
)
)
}Use the client as usual. The cache stores responses for requests that support caching and serves them when valid.
Cross-restart persistence depends on the origin server's cacheability headers per
RFC 7234 (for example Cache-Control: max-age=… or
Expires). This library persists whatever Ktor's HttpCache
plugin stores; it does not override freshness rules. Responses marked no-store are not written
to disk.
val response: String = client.get("https://example.com/api/data").body()CacheConfig supports:
| Property | Type | Default | Description |
|---|---|---|---|
enabled |
Boolean |
false |
Whether the HTTP cache is enabled. |
cacheDirectory |
String |
"http_cache" |
Name of the cache directory under the platform cache root. |
maxCacheSize |
Long |
10 MB | Maximum cache size in bytes. LRU eviction when exceeded. Use 0 for no limit. |
cacheTtl |
Long |
1 hour | Time-to-live for entries in milliseconds. Values <= 0 mean entries never expire (same convention as maxCacheSize <= 0 = unlimited). |
isShared |
Boolean |
true |
Whether the cache is shared across requests (Ktor HttpCache behavior). |
isPublic |
Boolean |
false |
When true, cached responses are treated as public (shareable across users); when false, they are private to the client. |
Convenience constructor:
CacheConfig(enabled = true, cacheDirectory = "my_cache")
// Other properties use defaults.This library installs Ktor's HttpCache and does not intercept the Auth pipeline. When you also install Auth, behavior follows Ktor's cache routing:
Cache-Control: private (for example private, max-age=3600) to
be stored in private storage — the Okio-backed store configured by installPersistentCache
when isPublic = false. Responses with only max-age (no private) route to Ktor's default
public storage, which this helper does not configure.isShared = false on CacheConfig when using Bearer (or other) auth on the same client.
Ktor skips cache lookup for authorized requests on a shared client and refuses to store private
entries when isShared = true.These rules are verified by AuthPluginInteropTest in the test suite. No extra configuration is
required beyond matching server cache headers and the CacheConfig flags above.
CacheStorageConfig covers only persisted disk knobs: subdirectory name under the platform (or custom) cache root, maximum size in bytes, and TTL in milliseconds.
CacheConfig
implements that shape and adds enabled, isShared, and isPublic, which installPersistentCache
respects alongside HttpCache.
CacheStorageFactory
produces Okio-backed CacheStorage. With enabled == true, installPersistentCache delegates here;
you can call the factory directly when configuring HttpCache yourself—for example with a fake
filesystem or deterministic clock in tests:
val storage = CacheStorageFactory.create(
config = CacheConfig(
enabled = true, // Ignored by the factory; use installPersistentCache to honor `enabled`
cacheDirectory = "http_cache",
maxCacheSize = 10L * 1024 * 1024,
cacheTtl = 60 * 60 * 1000,
),
cacheDirectoryProvider = getCacheDirectoryProvider()
)
HttpClient(CIO) {
install(HttpCache) {
isShared = true
privateStorage(storage)
}
}Important: Only installPersistentCache maps CacheConfig.enabled == false to CacheStorage.Disabled.
If you bypass it and always call CacheStorageFactory.create, you construct disk storage explicitly;
pair with your own HttpCache setup (or omit the plugin if you don’t want HTTP caching behavior).
To control where the cache is stored (e.g. a custom folder or test directory), implement CacheDirectoryProvider and pass it to installPersistentCache:
val customProvider = object : CacheDirectoryProvider {
override val cacheDirectory: Path get() = FileSystem.SYSTEM.toPath("/custom/cache/dir")
}
HttpClient(CIO) {
installPersistentCache(
config = CacheConfig(enabled = true, cacheDirectory = "http_cache"),
cacheDirectoryProvider = customProvider
)
}Default behavior (no custom provider): getCacheDirectoryProvider() returns the platform implementation (Android app cache dir, iOS caches dir, or JVM temp dir).
Build:
./gradlew :shared:compileKotlinIosSimulatorArm64 :shared:compileAndroidMain
# or
./gradlew :shared:assembleRun tests:
./gradlew testPublish to local Maven:
./gradlew :shared:publishToMavenLocalThen depend on io.github.santimattius:ktor-persistent-cache:1.0.0-SNAPSHOT with mavenLocal() in
your project.
The shared module is published with
the gradle-maven-publish-plugin.
| Action | Command / Doc |
|---|---|
| Publish to local Maven | ./gradlew :shared:publishToMavenLocal |
| Publish to Maven Central | See docs/PUBLISHING.md for credentials and steps. |
Coordinates and POM are configured in shared/build.gradle.kts.
This project is licensed under the Apache License, Version 2.0.
| Resource | URL |
|---|---|
| Ktor — HTTP client | ktor.io/docs/client |
| Ktor — Caching | ktor.io/docs/client-caching |
| Okio | github.com/square/okio |
| Kotlin Multiplatform | kotlinlang.org/docs/multiplatform |
| Publishing (this repo) | docs/PUBLISHING.md |
A Kotlin Multiplatform library that adds persistent HTTP caching to Ktor HttpClient. Responses are stored on disk using Okio, with configurable size limits, TTL, and platform-appropriate cache directories.
CacheStorage) with Okio persistence — the same backing store installPersistentCache uses when enabled is true.Vary headers so different variants (e.g. by
Accept-Language) are cached separately.| Platform | Cache directory |
|---|---|
| Android | Application cache dir (context.cacheDir) |
| iOS | App caches directory (NSCachesDirectory in the sandbox) |
| JVM | java.io.tmpdir/ktor-cache |
ktor-client-core 3.4.0+) and an engine (CIO, OkHttp, etc.) for your
targetsAdd the dependency to your shared or platform source sets.
Kotlin DSL (Gradle):
repositories {
mavenCentral()
// For snapshots:
// maven("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}
dependencies {
commonMain.dependencies {
implementation("io.github.santimattius:ktor-persistent-cache:1.0.0-SNAPSHOT")
}
// Also add a Ktor engine for each target, e.g.:
// implementation("io.ktor:ktor-client-okhttp") // Android
// implementation("io.ktor:ktor-client-cio") // iOS / JVM
}Version catalog (e.g. libs.versions.toml):
[versions]
ktorPersistentCache = "1.0.0-SNAPSHOT"
[libraries]
ktor-persistent-cache = { group = "io.github.santimattius", name = "ktor-persistent-cache", version.ref = "ktorPersistentCache" }Then:
implementation(libs.ktor.persistent.cache)The library needs the application context to resolve the cache directory. The recommended way is App Startup:
Merge the library’s manifest
The shared (or Android) module that depends on ktor-persistent-cache should merge the
library’s AndroidManifest so that the App Startup InitializationProvider and
ContextInitializer are registered.
No extra code
If the manifest is merged, ContextInitializer runs at app startup and injects the application
context. getCacheDirectoryProvider()
will then use it automatically.
If you don’t use the library’s manifest (e.g. you use a different DI or startup path), you must call once at app startup with the application context (not an Activity context):
import io.github.santimattius.persistent.cache.startup.injectContext
// e.g. in Application.onCreate()
injectContext(applicationContext)injectContext is a public API in io.github.santimattius.persistent.cache.startup. It throws
IllegalArgumentException if you pass a context that can leak memory (for example an Activity).
No setup. The library uses the default app caches directory.
No setup. The library uses a subdirectory of the JVM temp directory.
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.github.santimattius.persistent.cache.*
val client = HttpClient(CIO) {
installPersistentCache(
CacheConfig(
enabled = true,
cacheDirectory = "http_cache",
maxCacheSize = 10L * 1024 * 1024, // 10 MB
cacheTtl = 60 * 60 * 1000, // 1 hour
isShared = true,
isPublic = false
)
)
}Use the client as usual. The cache stores responses for requests that support caching and serves them when valid.
Cross-restart persistence depends on the origin server's cacheability headers per
RFC 7234 (for example Cache-Control: max-age=… or
Expires). This library persists whatever Ktor's HttpCache
plugin stores; it does not override freshness rules. Responses marked no-store are not written
to disk.
val response: String = client.get("https://example.com/api/data").body()CacheConfig supports:
| Property | Type | Default | Description |
|---|---|---|---|
enabled |
Boolean |
false |
Whether the HTTP cache is enabled. |
cacheDirectory |
String |
"http_cache" |
Name of the cache directory under the platform cache root. |
maxCacheSize |
Long |
10 MB | Maximum cache size in bytes. LRU eviction when exceeded. Use 0 for no limit. |
cacheTtl |
Long |
1 hour | Time-to-live for entries in milliseconds. Values <= 0 mean entries never expire (same convention as maxCacheSize <= 0 = unlimited). |
isShared |
Boolean |
true |
Whether the cache is shared across requests (Ktor HttpCache behavior). |
isPublic |
Boolean |
false |
When true, cached responses are treated as public (shareable across users); when false, they are private to the client. |
Convenience constructor:
CacheConfig(enabled = true, cacheDirectory = "my_cache")
// Other properties use defaults.This library installs Ktor's HttpCache and does not intercept the Auth pipeline. When you also install Auth, behavior follows Ktor's cache routing:
Cache-Control: private (for example private, max-age=3600) to
be stored in private storage — the Okio-backed store configured by installPersistentCache
when isPublic = false. Responses with only max-age (no private) route to Ktor's default
public storage, which this helper does not configure.isShared = false on CacheConfig when using Bearer (or other) auth on the same client.
Ktor skips cache lookup for authorized requests on a shared client and refuses to store private
entries when isShared = true.These rules are verified by AuthPluginInteropTest in the test suite. No extra configuration is
required beyond matching server cache headers and the CacheConfig flags above.
CacheStorageConfig covers only persisted disk knobs: subdirectory name under the platform (or custom) cache root, maximum size in bytes, and TTL in milliseconds.
CacheConfig
implements that shape and adds enabled, isShared, and isPublic, which installPersistentCache
respects alongside HttpCache.
CacheStorageFactory
produces Okio-backed CacheStorage. With enabled == true, installPersistentCache delegates here;
you can call the factory directly when configuring HttpCache yourself—for example with a fake
filesystem or deterministic clock in tests:
val storage = CacheStorageFactory.create(
config = CacheConfig(
enabled = true, // Ignored by the factory; use installPersistentCache to honor `enabled`
cacheDirectory = "http_cache",
maxCacheSize = 10L * 1024 * 1024,
cacheTtl = 60 * 60 * 1000,
),
cacheDirectoryProvider = getCacheDirectoryProvider()
)
HttpClient(CIO) {
install(HttpCache) {
isShared = true
privateStorage(storage)
}
}Important: Only installPersistentCache maps CacheConfig.enabled == false to CacheStorage.Disabled.
If you bypass it and always call CacheStorageFactory.create, you construct disk storage explicitly;
pair with your own HttpCache setup (or omit the plugin if you don’t want HTTP caching behavior).
To control where the cache is stored (e.g. a custom folder or test directory), implement CacheDirectoryProvider and pass it to installPersistentCache:
val customProvider = object : CacheDirectoryProvider {
override val cacheDirectory: Path get() = FileSystem.SYSTEM.toPath("/custom/cache/dir")
}
HttpClient(CIO) {
installPersistentCache(
config = CacheConfig(enabled = true, cacheDirectory = "http_cache"),
cacheDirectoryProvider = customProvider
)
}Default behavior (no custom provider): getCacheDirectoryProvider() returns the platform implementation (Android app cache dir, iOS caches dir, or JVM temp dir).
Build:
./gradlew :shared:compileKotlinIosSimulatorArm64 :shared:compileAndroidMain
# or
./gradlew :shared:assembleRun tests:
./gradlew testPublish to local Maven:
./gradlew :shared:publishToMavenLocalThen depend on io.github.santimattius:ktor-persistent-cache:1.0.0-SNAPSHOT with mavenLocal() in
your project.
The shared module is published with
the gradle-maven-publish-plugin.
| Action | Command / Doc |
|---|---|
| Publish to local Maven | ./gradlew :shared:publishToMavenLocal |
| Publish to Maven Central | See docs/PUBLISHING.md for credentials and steps. |
Coordinates and POM are configured in shared/build.gradle.kts.
This project is licensed under the Apache License, Version 2.0.
| Resource | URL |
|---|---|
| Ktor — HTTP client | ktor.io/docs/client |
| Ktor — Caching | ktor.io/docs/client-caching |
| Okio | github.com/square/okio |
| Kotlin Multiplatform | kotlinlang.org/docs/multiplatform |
| Publishing (this repo) | docs/PUBLISHING.md |