Open-source KMP library that handles two pain points stock OkHttp can't: caching non-idempotent queries (POST-as-query, GraphQL, search) and invalidating cached responses when mutations happen. Annotation-driven, converter-agnostic, targets Android, JVM, iOS, and wasmJs.
Problem
OkHttp's stock Cache only handles GET, only honors HTTP cache headers, and exposes no programmatic invalidation. Real apps don't fit that model. Search endpoints are POST. GraphQL is POST. Mutation responses change the truth of multiple GET endpoints downstream and there's no clean way to invalidate them atomically.
Existing community solutions for the JVM either tightly coupled to a specific serializer (Gson / Moshi / kotlinx) or assumed a single transport (Retrofit only). None were KMP-ready. So callers were rolling per-app hacks: hand-written response stores, header injection, manual map-of-keys-to-invalidate. Brittle.
Approach
Retrostash treats the cache as a first-class system controlled by annotations on the API surface, not a passive layer reacting to HTTP semantics. @CacheQuery declares what to cache and how to key it. @CacheMutate declares what to invalidate when this call succeeds. The engine sits outside the transport and lets each adapter (OkHttp / Retrofit, Ktor) feed it.
interface SearchApi {
@CacheQuery(key = "search:{q}", tags = ["search"])
@POST("/graphql")
suspend fun search(@Body req: GraphQlRequest): List<Hit>
@CacheMutate(invalidateTags = ["search"])
@POST("/items")
suspend fun createItem(@Body req: CreateItem): Item
}Design decisions
Converter-agnostic key resolution
Retrostash intercepts the raw RequestBody (OkHttp) or HttpRequestBuilder attributes (Ktor) and resolves {placeholders} from @Path, @Query, @Body. It never assumes a serializer. Bodies can be plain Kotlin objects, Maps, arrays, JSON bytes. Retrostash extracts the fields it needs without owning the encoder.
Tags for cross-API invalidation
A single @CacheMutate(invalidateTags = ["orders"]) clears every cached entry tagged orders, regardless of which endpoint produced it. This was the killer feature missing from competitors: mutations in one resource almost always invalidate caches in adjacent ones, and tag-based invalidation is the only sane way to express that without listing each cache key.
Modules
retrostash-core: engine, key resolver, in-memory store (Android, JVM, iOS targets)retrostash-annotations:@CacheQuery,@CacheMutateretrostash-ktor:HttpClientpluginretrostash-okhttp:Interceptor+ Retrofit metadata extractor (Android + JVM)
Each transport adapter pulls retrostash-core + retrostash-annotations transitively, so apps only depend on the transport module they use.
Multiplatform reach
The sample app (:composeApp) runs on Android, JVM desktop, iOS, and wasmJs in the browser. A segmented control swaps between Ktor, OkHttp, and Retrofit transports against the same cache so behavior is identical across stacks. iOS distribution is a single XCFramework via Swift Package Manager bundling core + annotations + Ktor plugin.
Outcome
- Published to Maven Central at
dev.logickoder:retrostash-{okhttp,ktor}:0.0.13 - Published to Swift Package Manager for iOS consumers
- Live playground at logickoder.dev/retrostash/playground demonstrating the three transports + tag invalidation
- Full KDoc on the public surface; integration tests covering OkHttp, Ktor, mutation invalidation
- Solves the POST-cache + mutation-invalidation problem that I and several teams had been hand-rolling for years
What I would do next
- Persistent store backends: currently in-memory; SQLDelight or Room (KMP) for app restarts
- Apollo GraphQL adapter: operation-level annotation surface
- Cadence policies: per-entry TTL with background revalidation rather than only mutation-driven invalidation
Tell me what breaks. Issues at github.com/logickoder/retrostash, PRs welcome.