
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 via an idiomatic client plugin DSL, with a storage backend you choose explicitly — Okio (default, stable) or kotlinx-io (experimental) — configurable size limits, TTL, and platform-appropriate cache directories.
Upgrading from a pre-1.2 version? The public API moved to a multi-module layout and a new
install(PersistentCache) { ... }DSL. See docs/MIGRATION.md — it leads with the one unavoidable breaking change (a customCacheDirectoryProviderneeds a manual rewrite) before covering everything else, which is a source- and binary-compatible deprecation.
install(PersistentCache) { ... } client plugin built on
Ktor's own HttpCache; you configure storage and
options in one place.cache-okio (default, stable) or
cache-kotlinx-io (experimental, opt-in), both implementing the same
CacheFileSystem SPI on top of one shared caching algorithm in
cache-core, so switching backends does not invalidate an existing on-disk cache.Vary headers so different variants (e.g. by
Accept-Language) are cached separately.CacheDirectoryProvider for
custom cache root paths (e.g. for tests or special directories).| 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
targetsThis library ships as four Maven artifacts. Pick one of the two paths below.
Path A — just depend on ktor-persistent-cache (recommended for most users): it transitively
pulls in cache-core and the default cache-okio backend, so install(PersistentCache) { ... }
works with no extra setup.
dependencies {
commonMain.dependencies {
implementation("io.github.santimattius:ktor-persistent-cache:1.2.0")
}
// 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
}Path B — depend on cache-core plus a backend directly, without the :shared facade — use
this if you want the experimental cache-kotlinx-io backend instead, or want the smallest
possible dependency surface:
dependencies {
commonMain.dependencies {
implementation("io.github.santimattius:ktor-persistent-cache-core:1.2.0")
implementation("io.github.santimattius:ktor-persistent-cache-okio:1.2.0")
// or, instead of the line above:
// implementation("io.github.santimattius:ktor-persistent-cache-kotlinx-io:1.2.0")
}
}# gradle/libs.versions.toml
[versions]
ktorPersistentCache = "1.2.0"
[libraries]
ktor-persistent-cache = { group = "io.github.santimattius", name = "ktor-persistent-cache", version.ref = "ktorPersistentCache" }
ktor-persistent-cache-core = { group = "io.github.santimattius", name = "ktor-persistent-cache-core", version.ref = "ktorPersistentCache" }
ktor-persistent-cache-okio = { group = "io.github.santimattius", name = "ktor-persistent-cache-okio", version.ref = "ktorPersistentCache" }
ktor-persistent-cache-kotlinx-io = { group = "io.github.santimattius", name = "ktor-persistent-cache-kotlinx-io", version.ref = "ktorPersistentCache" }repositories {
mavenCentral()
// For snapshots:
// maven("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}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 (now in cache-core) 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.
install(PersistentCache):import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.github.santimattius.persistent.cache.*
@OptIn(InternalPersistentCacheApi::class)
val client = HttpClient(CIO) {
install(PersistentCache) {
directory = "http_cache"
maxSize = 10L * 1024 * 1024 // 10 MB
ttl = 60 * 60 * 1000 // 1 hour
shared = true
public = false
fileSystem = OkioCacheFileSystem() // the default, stable backend — see "Choosing a backend"
}
}fileSystem is required: cache-core ships zero I/O dependencies by design, so you choose a
backend explicitly (see Choosing a backend below). fileSystem and
directoryProvider are typed against a backend SPI annotated @InternalPersistentCacheApi — not a
bug, an intentional signal that the SPI itself isn't a stability-guaranteed surface for application
code the way the rest of PersistentCacheConfig is. Add
@OptIn(InternalPersistentCacheApi::class) where you call install(PersistentCache).
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()CacheDirectoryProvider via directoryProvider
(see Custom cache directory).PersistentCacheConfig (configured inside install(PersistentCache) { ... }) supports:
| Property | Type | Default | Description |
|---|---|---|---|
directory |
String |
"http_cache" |
Name of the cache directory under the platform cache root. |
maxSize |
Long |
10 MB | Maximum cache size in bytes. LRU eviction when exceeded. Use 0 for no limit. |
ttl |
Long |
1 hour | Time-to-live for entries in milliseconds. Values <= 0 mean entries never expire (same convention as maxSize <= 0 = unlimited). |
shared |
Boolean |
true |
Whether the cache is shared across requests (Ktor HttpCache behavior). |
public |
Boolean |
false |
When true, cached responses are treated as public (shareable across users); when false, they are private to the client. |
fileSystem |
CacheFileSystem<*>? |
null |
Required. The storage backend — e.g. OkioCacheFileSystem() or KotlinxIoCacheFileSystem(). @InternalPersistentCacheApi. |
directoryProvider |
CacheDirectoryProvider? |
null |
Optional custom cache root; defaults to the platform-specific getCacheDirectoryProvider(). @InternalPersistentCacheApi. |
clock |
() -> Long |
getTimeMillis() |
Supplies the current time; override for deterministic tests. |
There is no direct enabled toggle: to disable caching, omit install(PersistentCache) entirely.
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 backend-persisted store configured by
install(PersistentCache) when public = false. Responses with only max-age (no private)
route to Ktor's default public storage, which this plugin does not configure.shared = false on PersistentCacheConfig 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 shared = true.These rules are verified by AuthPluginInteropTest (in cache-okio's test suite, exercised against
the real install(PersistentCache) DSL). No extra configuration is required beyond matching server
cache headers and the PersistentCacheConfig flags above.
The library is split across four published modules:
| Module | Artifact | Contains |
|---|---|---|
cache-core |
ktor-persistent-cache-core |
The install(PersistentCache) { ... } DSL, the shared caching algorithm (FileCacheStorage), the CacheFileSystem backend SPI, and CacheDirectoryProvider. Zero I/O dependencies — no Okio, no kotlinx-io. |
cache-okio |
ktor-persistent-cache-okio |
OkioCacheFileSystem — the default, stable backend, implementing CacheFileSystem<okio.Path>. |
cache-kotlinx-io |
ktor-persistent-cache-kotlinx-io |
KotlinxIoCacheFileSystem — an experimental, opt-in backend, implementing CacheFileSystem<kotlinx.io.files.Path>. |
ktor-persistent-cache (the :shared module) |
ktor-persistent-cache |
A compatibility facade depending on cache-core + cache-okio, keeping the pre-1.2 Maven coordinate working. Also where ContextInitializer's Android manifest merge lives (via cache-core). |
Both backends implement the same CacheFileSystem<P> contract, pass the same shared conformance
test suite, and produce byte-identical on-disk cache filenames for the same input (SHA-256-based
keys) — switching backends does not invalidate an existing on-disk cache.
cache-okio — default, stable, ships transitively via ktor-persistent-cache:
@OptIn(InternalPersistentCacheApi::class)
val client = HttpClient(CIO) {
install(PersistentCache) {
directory = "http_cache"
fileSystem = OkioCacheFileSystem() // defaults to okio.FileSystem.SYSTEM
}
}cache-kotlinx-io — experimental, opt-in, not pulled in by ktor-persistent-cache; add the
ktor-persistent-cache-kotlinx-io artifact directly (see Installation, Path B):
@OptIn(InternalPersistentCacheApi::class, ExperimentalKotlinxIoCache::class)
val client = HttpClient(CIO) {
install(PersistentCache) {
directory = "http_cache"
fileSystem = KotlinxIoCacheFileSystem() // defaults to kotlinx.io.files.SystemFileSystem
}
}KotlinxIoCacheFileSystem requires the extra ExperimentalKotlinxIoCache opt-in (WARNING-level)
because it tracks kotlinx-io's own Alpha-stability kotlinx.io.files package and may change shape
between minor versions of this library. Okio remains the recommended default backend for
production use; choose cache-kotlinx-io only if you already depend on kotlinx-io and want to avoid
pulling in Okio.
See docs/MIGRATION.md if you're upgrading from a version that used
CacheStorageFactory/CacheConfig directly.
To control where the cache is stored (e.g. a custom folder or test directory), implement
CacheDirectoryProvider and assign it to directoryProvider:
val customProvider = object : CacheDirectoryProvider {
override val cacheDirectory: String get() = "/custom/cache/dir"
}
@OptIn(InternalPersistentCacheApi::class)
val client = HttpClient(CIO) {
install(PersistentCache) {
directory = "http_cache"
fileSystem = OkioCacheFileSystem()
directoryProvider = customProvider
}
}Default behavior (no custom provider): getCacheDirectoryProvider()
returns the platform implementation (Android app cache dir, iOS caches dir, or JVM temp dir).
Note cacheDirectory here is a plain String, not an okio.Path — see
docs/MIGRATION.md if you're upgrading a pre-1.2 custom provider.
Build:
./gradlew buildRun tests and API checks (all modules):
./gradlew check apiCheckPublish to local Maven:
./gradlew publishToMavenLocalThen depend on io.github.santimattius:ktor-persistent-cache:1.2.0 (or one of the other three
artifacts) with mavenLocal() in your project.
Each publishable module (shared, cache-core, cache-okio, cache-kotlinx-io) is published with
the gradle-maven-publish-plugin, and
gated by a committed binary-compatibility-validator
baseline (apiCheck) — unreviewed public API changes fail CI.
| Action | Command / Doc |
|---|---|
| Publish to local Maven | ./gradlew publishToMavenLocal |
| Publish to Maven Central | See docs/PUBLISHING.md for credentials and steps. |
Coordinates and POM are configured in each module's build.gradle.kts.
This project is licensed under the Apache License, Version 2.0.
| Resource | URL |
|---|---|
| Migration guide (1.2.0) | docs/MIGRATION.md |
| Ktor — HTTP client | ktor.io/docs/client |
| Ktor — Caching | ktor.io/docs/client-caching |
| Okio | github.com/square/okio |
| kotlinx-io | github.com/Kotlin/kotlinx-io |
| Kotlin Multiplatform | kotlinlang.org/docs/multiplatform |
| Publishing (this repo) | docs/PUBLISHING.md |
A Kotlin Multiplatform library that adds persistent HTTP caching to Ktor HttpClient via an idiomatic client plugin DSL, with a storage backend you choose explicitly — Okio (default, stable) or kotlinx-io (experimental) — configurable size limits, TTL, and platform-appropriate cache directories.
Upgrading from a pre-1.2 version? The public API moved to a multi-module layout and a new
install(PersistentCache) { ... }DSL. See docs/MIGRATION.md — it leads with the one unavoidable breaking change (a customCacheDirectoryProviderneeds a manual rewrite) before covering everything else, which is a source- and binary-compatible deprecation.
install(PersistentCache) { ... } client plugin built on
Ktor's own HttpCache; you configure storage and
options in one place.cache-okio (default, stable) or
cache-kotlinx-io (experimental, opt-in), both implementing the same
CacheFileSystem SPI on top of one shared caching algorithm in
cache-core, so switching backends does not invalidate an existing on-disk cache.Vary headers so different variants (e.g. by
Accept-Language) are cached separately.CacheDirectoryProvider for
custom cache root paths (e.g. for tests or special directories).| 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
targetsThis library ships as four Maven artifacts. Pick one of the two paths below.
Path A — just depend on ktor-persistent-cache (recommended for most users): it transitively
pulls in cache-core and the default cache-okio backend, so install(PersistentCache) { ... }
works with no extra setup.
dependencies {
commonMain.dependencies {
implementation("io.github.santimattius:ktor-persistent-cache:1.2.0")
}
// 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
}Path B — depend on cache-core plus a backend directly, without the :shared facade — use
this if you want the experimental cache-kotlinx-io backend instead, or want the smallest
possible dependency surface:
dependencies {
commonMain.dependencies {
implementation("io.github.santimattius:ktor-persistent-cache-core:1.2.0")
implementation("io.github.santimattius:ktor-persistent-cache-okio:1.2.0")
// or, instead of the line above:
// implementation("io.github.santimattius:ktor-persistent-cache-kotlinx-io:1.2.0")
}
}# gradle/libs.versions.toml
[versions]
ktorPersistentCache = "1.2.0"
[libraries]
ktor-persistent-cache = { group = "io.github.santimattius", name = "ktor-persistent-cache", version.ref = "ktorPersistentCache" }
ktor-persistent-cache-core = { group = "io.github.santimattius", name = "ktor-persistent-cache-core", version.ref = "ktorPersistentCache" }
ktor-persistent-cache-okio = { group = "io.github.santimattius", name = "ktor-persistent-cache-okio", version.ref = "ktorPersistentCache" }
ktor-persistent-cache-kotlinx-io = { group = "io.github.santimattius", name = "ktor-persistent-cache-kotlinx-io", version.ref = "ktorPersistentCache" }repositories {
mavenCentral()
// For snapshots:
// maven("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}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 (now in cache-core) 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.
install(PersistentCache):import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.github.santimattius.persistent.cache.*
@OptIn(InternalPersistentCacheApi::class)
val client = HttpClient(CIO) {
install(PersistentCache) {
directory = "http_cache"
maxSize = 10L * 1024 * 1024 // 10 MB
ttl = 60 * 60 * 1000 // 1 hour
shared = true
public = false
fileSystem = OkioCacheFileSystem() // the default, stable backend — see "Choosing a backend"
}
}fileSystem is required: cache-core ships zero I/O dependencies by design, so you choose a
backend explicitly (see Choosing a backend below). fileSystem and
directoryProvider are typed against a backend SPI annotated @InternalPersistentCacheApi — not a
bug, an intentional signal that the SPI itself isn't a stability-guaranteed surface for application
code the way the rest of PersistentCacheConfig is. Add
@OptIn(InternalPersistentCacheApi::class) where you call install(PersistentCache).
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()CacheDirectoryProvider via directoryProvider
(see Custom cache directory).PersistentCacheConfig (configured inside install(PersistentCache) { ... }) supports:
| Property | Type | Default | Description |
|---|---|---|---|
directory |
String |
"http_cache" |
Name of the cache directory under the platform cache root. |
maxSize |
Long |
10 MB | Maximum cache size in bytes. LRU eviction when exceeded. Use 0 for no limit. |
ttl |
Long |
1 hour | Time-to-live for entries in milliseconds. Values <= 0 mean entries never expire (same convention as maxSize <= 0 = unlimited). |
shared |
Boolean |
true |
Whether the cache is shared across requests (Ktor HttpCache behavior). |
public |
Boolean |
false |
When true, cached responses are treated as public (shareable across users); when false, they are private to the client. |
fileSystem |
CacheFileSystem<*>? |
null |
Required. The storage backend — e.g. OkioCacheFileSystem() or KotlinxIoCacheFileSystem(). @InternalPersistentCacheApi. |
directoryProvider |
CacheDirectoryProvider? |
null |
Optional custom cache root; defaults to the platform-specific getCacheDirectoryProvider(). @InternalPersistentCacheApi. |
clock |
() -> Long |
getTimeMillis() |
Supplies the current time; override for deterministic tests. |
There is no direct enabled toggle: to disable caching, omit install(PersistentCache) entirely.
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 backend-persisted store configured by
install(PersistentCache) when public = false. Responses with only max-age (no private)
route to Ktor's default public storage, which this plugin does not configure.shared = false on PersistentCacheConfig 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 shared = true.These rules are verified by AuthPluginInteropTest (in cache-okio's test suite, exercised against
the real install(PersistentCache) DSL). No extra configuration is required beyond matching server
cache headers and the PersistentCacheConfig flags above.
The library is split across four published modules:
| Module | Artifact | Contains |
|---|---|---|
cache-core |
ktor-persistent-cache-core |
The install(PersistentCache) { ... } DSL, the shared caching algorithm (FileCacheStorage), the CacheFileSystem backend SPI, and CacheDirectoryProvider. Zero I/O dependencies — no Okio, no kotlinx-io. |
cache-okio |
ktor-persistent-cache-okio |
OkioCacheFileSystem — the default, stable backend, implementing CacheFileSystem<okio.Path>. |
cache-kotlinx-io |
ktor-persistent-cache-kotlinx-io |
KotlinxIoCacheFileSystem — an experimental, opt-in backend, implementing CacheFileSystem<kotlinx.io.files.Path>. |
ktor-persistent-cache (the :shared module) |
ktor-persistent-cache |
A compatibility facade depending on cache-core + cache-okio, keeping the pre-1.2 Maven coordinate working. Also where ContextInitializer's Android manifest merge lives (via cache-core). |
Both backends implement the same CacheFileSystem<P> contract, pass the same shared conformance
test suite, and produce byte-identical on-disk cache filenames for the same input (SHA-256-based
keys) — switching backends does not invalidate an existing on-disk cache.
cache-okio — default, stable, ships transitively via ktor-persistent-cache:
@OptIn(InternalPersistentCacheApi::class)
val client = HttpClient(CIO) {
install(PersistentCache) {
directory = "http_cache"
fileSystem = OkioCacheFileSystem() // defaults to okio.FileSystem.SYSTEM
}
}cache-kotlinx-io — experimental, opt-in, not pulled in by ktor-persistent-cache; add the
ktor-persistent-cache-kotlinx-io artifact directly (see Installation, Path B):
@OptIn(InternalPersistentCacheApi::class, ExperimentalKotlinxIoCache::class)
val client = HttpClient(CIO) {
install(PersistentCache) {
directory = "http_cache"
fileSystem = KotlinxIoCacheFileSystem() // defaults to kotlinx.io.files.SystemFileSystem
}
}KotlinxIoCacheFileSystem requires the extra ExperimentalKotlinxIoCache opt-in (WARNING-level)
because it tracks kotlinx-io's own Alpha-stability kotlinx.io.files package and may change shape
between minor versions of this library. Okio remains the recommended default backend for
production use; choose cache-kotlinx-io only if you already depend on kotlinx-io and want to avoid
pulling in Okio.
See docs/MIGRATION.md if you're upgrading from a version that used
CacheStorageFactory/CacheConfig directly.
To control where the cache is stored (e.g. a custom folder or test directory), implement
CacheDirectoryProvider and assign it to directoryProvider:
val customProvider = object : CacheDirectoryProvider {
override val cacheDirectory: String get() = "/custom/cache/dir"
}
@OptIn(InternalPersistentCacheApi::class)
val client = HttpClient(CIO) {
install(PersistentCache) {
directory = "http_cache"
fileSystem = OkioCacheFileSystem()
directoryProvider = customProvider
}
}Default behavior (no custom provider): getCacheDirectoryProvider()
returns the platform implementation (Android app cache dir, iOS caches dir, or JVM temp dir).
Note cacheDirectory here is a plain String, not an okio.Path — see
docs/MIGRATION.md if you're upgrading a pre-1.2 custom provider.
Build:
./gradlew buildRun tests and API checks (all modules):
./gradlew check apiCheckPublish to local Maven:
./gradlew publishToMavenLocalThen depend on io.github.santimattius:ktor-persistent-cache:1.2.0 (or one of the other three
artifacts) with mavenLocal() in your project.
Each publishable module (shared, cache-core, cache-okio, cache-kotlinx-io) is published with
the gradle-maven-publish-plugin, and
gated by a committed binary-compatibility-validator
baseline (apiCheck) — unreviewed public API changes fail CI.
| Action | Command / Doc |
|---|---|
| Publish to local Maven | ./gradlew publishToMavenLocal |
| Publish to Maven Central | See docs/PUBLISHING.md for credentials and steps. |
Coordinates and POM are configured in each module's build.gradle.kts.
This project is licensed under the Apache License, Version 2.0.
| Resource | URL |
|---|---|
| Migration guide (1.2.0) | docs/MIGRATION.md |
| Ktor — HTTP client | ktor.io/docs/client |
| Ktor — Caching | ktor.io/docs/client-caching |
| Okio | github.com/square/okio |
| kotlinx-io | github.com/Kotlin/kotlinx-io |
| Kotlin Multiplatform | kotlinlang.org/docs/multiplatform |
| Publishing (this repo) | docs/PUBLISHING.md |