Stop Using Room to Cache POST Requests: The Missing Layer Between Retrofit and OkHttp
Introducing Retrostash: The missing layer for caching non-idempotent Retrofit requests (and automatic invalidation).
There is an unspoken, collective groan in the Android community whenever a backend team decides to use POST requests for fetching data.
Whether it’s a GraphQL endpoint, a complex search payload with 50 filters, or a massive analytics query, the moment you change @GET to @POST in Retrofit, you lose the most powerful free feature in Android networking: OkHttp’s native caching.
According to the HTTP/1.1 RFC, GET requests are idempotent and cacheable, while POST requests are mutations and strictly non-cacheable. OkHttp enforces this dogma at its core. If it sees a POST, it bypasses the cache entirely.
This was fine in 2010. In 2026, it is an architectural nightmare.
The Bloated Workarounds
Because OkHttp refuses to cache POST requests, Android developers have resorted to massive architectural workarounds just to get offline support or prevent redundant network calls:
The Database Crutch: You let the network call fire, parse the JSON into a Kotlin data class, map it to an Entity, and save it to a Room database. You then observe the database to update your UI. You are spinning up an entire SQLite database and writing thousands of lines of boilerplate just to mimic a simple HTTP cache.
The “Roll Your Own” Interceptor: You try to write a custom OkHttp Interceptor to cache the byte streams. But then you hit the hardest problem in computer science: Cache Invalidation.
The Invalidation Nightmare
Caching a payload is easy. Knowing exactly when to delete it is where architectures go to die.
If you cache a POST query that fetches a list of users, what happens when the user updates their profile via a PUT or POST mutation? Your cache is now stale. If you don’t aggressively invalidate it, your user sees old data and assumes your app is broken. If you wrote a custom OkHttp cache, OkHttp has no idea that /users/update has anything to do with /users/search. It just sees two random URLs.
Enter Retrostash
I got tired of writing Room databases for simple network caching. So I built Retrostash: an annotation-driven caching layer that bridges the knowledge gap between Retrofit’s application logic and OkHttp’s network execution.
Retrostash does two things:
It safely caches complex, non-idempotent queries (POST/GraphQL) to an access-order LRU disk cache.
It handles mutation-driven cache invalidation completely automatically.
Here is what it looks like in practice:
interface UserApi {
// 1. A complex POST query that we want to cache locally
@CacheQuery("users/{id}?tenant={tenant}")
@POST("users/{id}")
suspend fun getUser(
@Path("id") id: String,
@Body req: UserRequest, // Retrostash automatically extracts {tenant} from this body
): UserResponse
// 2. A mutation that immediately invalidates the cache from #1
@CacheMutate(invalidate = ["users/{id}?tenant={tenant}"])
@POST("users/{id}/update")
suspend fun updateUser(
@Path("id") id: String,
@Body req: UpdateUserRequest, // Retrostash builds the invalidation key from these parameters
): UpdateUserResponse
}
When you fire the updateUser mutation, Retrostash intercepts the request at the OkHttp layer. Using Retrofit’s Invocation API, it reflects back up to the interface and reads your @CacheMutate annotation. It reads your @Path, your @Query, and extracts values directly from your @Body to figure out exactly which cache key to nuke.
The very next time your app calls getUser(), Retrostash forces a network refresh and quietly updates the stored payload with the fresh data.
Real-World Edge Cases: Manual Invalidation
Automatic mutation caching is great, but what if there is no mutation? What if you just have a “Pull-to-Refresh” action in your UI, and you need to force-invalidate a cached GET or POST query?
Retrostash exposes the internal invalidation engine so you can manually mark keys as dirty from anywhere in your app:
Kotlin
val retrostash = Retrostash.from(okHttpClient) ?: return
// Nuke the cache for this specific user and tenant
retrostash.invalidateQuery(
apiClass = UserApi::class.java,
template = "users/{id}?tenant={tenant}",
bindings = mapOf(
"id" to "42",
"tenant" to "acme",
),
)
100% Converter Agnostic (No Gson Lock-in)
The easiest way to extract data from a @Body would be to use reflection and a library like Gson. But forcing Gson into an app that uses Moshi or kotlinx.serialization is a massive anti-pattern.
Retrostash has zero transitive JSON dependencies.
Instead of fighting your serialization library, Retrostash waits until Retrofit converts your data class into a raw OkHttp RequestBody. It reads those raw UTF-8 bytes and uses Android’s native org.json to traverse the payload. It doesn’t matter if you use @SerialName or @Json. Retrostash reads the exact wire-format JSON the server is going to see.
Stop Writing Boilerplate
You can install Retrostash into your OkHttp client with a single method call:
val okHttpBuilder = OkHttpClient.Builder().cache(cache)
// Install Retrostash with sane defaults
val retrostash = Retrostash.install(
builder = okHttpBuilder,
context = appContext
)
val okHttpClient = okHttpBuilder.build()
If you are maintaining complex Room databases just to cache GraphQL queries or search POSTs, you are fighting the framework. Drop the boilerplate. Keep the cache.
Full installation guidelines, configuration options (TTLs, LRU constraints), and advanced manual wiring instructions are in the README.
Check out the source and grab the dependency on GitHub:


