A Kotlin Multiplatform library that prevents duplicate concurrent HTTP requests in Ktor clients.
When multiple components request the same resource simultaneously, only one actual HTTP request is executed, and all callers receive the same response. This optimizes network usage and reduces server load.
- ✅ JVM (Java 8+)
- ✅ Android (API 21+)
- ✅ iOS (arm64, x64, simulatorArm64)
- ✅ macOS (x64, arm64)
- ✅ Linux (x64)
- ✅ JavaScript (Browser, Node.js)
All platforms from v2.x.x plus:
- ✅ Android Native (arm32, arm64, x86, x64)
- ✅ tvOS (x64, arm64, simulatorArm64)
- ✅ watchOS (arm32, arm64, x64, simulatorArm64, deviceArm64)
- ✅ Linux ARM64
- ✅ Windows (mingwX64)
- ✅ WebAssembly (wasmJs) - Browser and Node.js
| If you use Ktor | Use plugin version |
|---|---|
| 2.3.0 to 2.x.x | 2.x.x ← Use this |
| 3.0.0+ | 3.x.x ← Use this |
Add to your build.gradle.kts:
dependencies {
implementation("io.github.tiper:ktor-client-deduplication:2.x.x")
}Add to your build.gradle.kts:
dependencies {
implementation("io.github.tiper:ktor-client-deduplication:3.x.x")
}val client = HttpClient {
install(RequestDeduplication)
}
// Multiple concurrent GET requests to the same URL
launch { client.get("https://api.example.com/users") }
launch { client.get("https://api.example.com/users") }
launch { client.get("https://api.example.com/users") }
// Result: Only ONE actual HTTP request is made!
// All three callers receive the same shared responsePlugin order matters! The order affects what gets included in the deduplication cache key.
- Plugins BEFORE RequestDeduplication: Their modifications (headers, auth tokens, etc.) are included in the cache key. Use this for plugins you want to affect deduplication behavior.
- Plugins AFTER RequestDeduplication: Their modifications happen after deduplication, so they don't affect the cache key. Use this for plugins that shouldn't interfere with deduplication.
Example:
val client = HttpClient {
// Install BEFORE if you want their effects in the cache key
install(DefaultRequest) { ... } // Headers add to cache key
install(Auth) { ... } // Token adds to cache key
install(RequestDeduplication) // Deduplication based on above
// Install AFTER if you don't want them affecting deduplication
install(OtherAuth) { ... } // Token that doesn't affect cache key
install(Logging) { ... } // Logs response, doesn't affect cache key
install(HttpTimeout) { ... } // Timeout applies after dedup
}Consider your requirements and test to ensure the plugin order matches your expected behavior.
val client = HttpClient {
install(RequestDeduplication) {
// Deduplicate both GET and HEAD requests
deduplicateMethods = setOf(HttpMethod.Get, HttpMethod.Head)
// Exclude tracing/telemetry headers from cache key computation
excludeHeaders = setOf(
"X-Trace-Id",
"X-Request-Id",
"X-Correlation-Id",
"traceparent",
"tracestate"
)
// Optional: Add minimum deduplication window for fast responses
// Useful when error responses or cached data return very quickly
// minWindow = 50 // milliseconds (default: 0)
}
}- Request Interception: When a request is made, the plugin generates a cache key from the HTTP method, URL, and headers (excluding those in
excludeHeaders). - Deduplication Check: If another request with the same cache key is in-flight, the plugin waits for it to complete. Deduplication only applies to concurrent requests; sequential requests are not deduplicated.
- Response Sharing: The first request's response body is read once into memory and shared with all waiting callers (including error responses). The response is not cached - it's only shared during the concurrent execution window.
- Cleanup: After all concurrent callers complete, the shared response is released and the entry is removed. Subsequent requests will trigger new HTTP calls.
The cache key is built from:
- HTTP method (GET, POST, etc.)
- Full URL including query parameters
- Request headers (excluding those in
excludeHeaders)
Headers are combined using polynomial rolling hash to ensure order-independence and collision resistance:
GET:https://api.example.com/users?id=123|h=1847563829
Type: Set<HttpMethod>
Default: setOf(HttpMethod.Get)
HTTP methods to deduplicate. Typically you only want to deduplicate idempotent methods (GET, HEAD). You can add POST if your use case allows.
deduplicateMethods = setOf(HttpMethod.Get, HttpMethod.Head, HttpMethod.Post)Type: Long (milliseconds)
Default: 0 (no delay)
Minimum deduplication window (in milliseconds). This adds an artificial delay to ensure fast responses (like errors or cached responses) wait long enough for concurrent requests to join the deduplication window.
When to use:
- Fast error responses that complete before other concurrent requests arrive
- Cached responses that return almost instantly
- You want to maximize deduplication effectiveness for rapid requests
Trade-off: Higher values increase deduplication effectiveness but can add latency to fast requests by enforcing a minimum response window (slower requests are not delayed further)
Example:
install(RequestDeduplication) {
minWindow = 50 // Wait at least 50ms before completing
}Recommended: Start with 50-100ms if you have very fast responses and measure the impact.
Type: Set<String>
Default: emptySet()
Headers to exclude from cache key computation (case-sensitive). This is crucial for preventing tracing/telemetry headers from breaking deduplication.
Common headers to exclude:
| Category | Headers |
|---|---|
| Distributed Tracing | X-Trace-Id, X-Request-Id, X-Correlation-Id |
| Zipkin/B3 | X-B3-TraceId, X-B3-SpanId, X-B3-ParentSpanId, X-B3-Sampled |
| W3C Trace Context | traceparent, tracestate |
| Firebase | X-Firebase-Locale, X-Firebase-Auth-Time |
| AWS | X-Amzn-Trace-Id |
| Google Cloud | X-Cloud-Trace-Context |
Example:
excludeHeaders = setOf(
"X-Trace-Id",
"X-Request-Id",
"traceparent"
)If deduplication isn't working as expected, check the actual header names sent by your SDK:
val client = HttpClient {
install(Logging) {
level = LogLevel.HEADERS
}
install(RequestDeduplication) {
// Add headers after verifying their exact casing
excludeHeaders = setOf("X-Custom-Header")
}
}- Multiple UI components loading the same data on screen initialization
- Retry logic that might trigger duplicate requests
- Race conditions in concurrent code
- Apps with aggressive data prefetching
- Reducing server load from duplicate requests
- Large file downloads (entire response loaded into memory)
- Streaming responses
- POST/PUT/PATCH requests (non-idempotent operations) unless explicitly enabled
- Requests where each call should be independent
The plugin reads the response body once into memory and shares it (not copies) across all concurrent callers. This is highly memory-efficient:
Memory behavior:
- 1MB response with 10 concurrent callers = 1MB memory usage (not 10MB)
- Response body is loaded into a single ByteArray
- All callers read from the same shared ByteArray
- Memory is freed after all concurrent callers complete
Implications:
- Small JSON/XML responses: ✅ No problem (e.g., 10KB × 1 = 10KB)
- Large responses with many callers: ✅ Efficient (e.g., 1MB × 1 = 1MB, not 1MB × 10)
- Very large files (>10MB):
⚠️ Still loads entire response into memory - Mobile devices: ✅ Memory-efficient due to sharing
- Streaming responses: ❌ Not supported (entire body must be loaded)
The implementation is thread-safe and uses Kotlin's Mutex for synchronization. It's safe to use with concurrent coroutines across all platforms.
- Cache key generation: O(n) where n = number of headers
- Deduplication check: O(1) HashMap lookup
- Overhead: Negligible compared to network latency (~microseconds vs milliseconds)
Error responses (e.g., 404, 500) are also deduplicated and shared with all waiting callers.
The plugin handles cancellation gracefully to ensure robustness:
Individual caller cancellation:
- If one caller cancels, other concurrent callers continue normally and receive the response
- The cancelled caller gets a
CancellationExceptionas expected - This is achieved using a supervisor scope for the in-flight request
All callers cancel:
- If all concurrent callers cancel before the response arrives, the in-flight HTTP request is cancelled automatically
- This saves network bandwidth and server resources
- The plugin tracks active waiters and cancels the request when the count reaches zero
Example:
// Caller 1 starts request
launch { client.get("https://api.example.com/data") }
// Caller 2 joins (waits for same request)
val job = launch { client.get("https://api.example.com/data") }
// Caller 2 cancels
job.cancel() // ✅ Caller 2 is cancelled, Caller 1 still gets response
// If both cancel before response arrives, the HTTP request is cancelledIn unit tests, add artificial latency (e.g., delay(100)) in your mock handler to ensure concurrent requests overlap and deduplication is triggered reliably:
val client = mockClient {
delay(100) // Simulate network latency
requestCount++
"response-$requestCount"
}This library uses semantic versioning aligned with Ktor major versions:
| Plugin Version | Ktor Version | Kotlin Version | |---|---|----------------|---|---| | 2.x.x | 2.3.0+ | 1.9.20+ | | 3.x.x | 3.0.0+ | 2.0.20+ |
Both versions support the same platforms:
- ✅ JVM (Java 8+)
- ✅ Android (API 21+)
- ✅ iOS (arm64, x64, simulatorArm64)
- ✅ macOS (x64, arm64)
- ✅ Linux (x64)
- ✅ JavaScript (Browser, Node.js)
Version 3.x additionally supports:
Apple Ecosystem:
- ✅ tvOS (x64, arm64, simulatorArm64)
- ✅ watchOS (arm32, arm64, x64, simulatorArm64, deviceArm64)
Mobile:
- ✅ Android Native (arm32, arm64, x86, x64)
Desktop:
- ✅ Windows (mingwX64)
- ✅ Linux ARM64
Web:
- ✅ WebAssembly (wasmJs) - Browser and Node.js
Copyright 2026 Tiago Pereira
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Contributions are welcome! Please feel free to submit a Pull Request.