kmp-event-limiter
1.0.0indexedProduction-ready throttle and debounce toolkit for UI events, featuring async-safe click modifiers, AsyncButton and debounced inputs, plus configurable concurrency modes: Drop, Enqueue, Replace, Keep Latest.
Production-ready throttle and debounce toolkit for UI events, featuring async-safe click modifiers, AsyncButton and debounced inputs, plus configurable concurrency modes: Drop, Enqueue, Replace, Keep Latest.
Production-ready throttle and debounce for Kotlin Multiplatform & Compose Multiplatform.
Stop wrestling with coroutine boilerplate, race conditions, and state management. Handle button spam, search debouncing, and async operations with Kotlin-first design leveraging Coroutines and Compose.
Inspired by flutter_event_limiter but redesigned for the Kotlin/Compose ecosystem with idiomatic patterns.
Kotlin-First Design:
suspend functions, not callbacks.throttleClick()Production Ready:
Developer Experience:
// Traditional approach - lots of boilerplate
val scope = rememberCoroutineScope()
var isLoading by remember { mutableStateOf(false) }
Button(
onClick = {
if (!isLoading) {
scope.launch {
isLoading = true
try {
submitForm()
} finally {
isLoading = false
}
}
}
},
enabled = !isLoading
) {
if (isLoading) CircularProgressIndicator() else Text("Submit")
}
// With modifier - 1 line
Button(
onClick = {},
modifier = Modifier.asyncThrottleClick { submitForm() }
) {
Text("Submit")
}
// With AsyncButton - automatic loading state
AsyncButton(
onClick = { submitForm() }
) { isLoading ->
if (isLoading) CircularProgressIndicator() else Text("Submit")
}
Result: 80% less code. Auto-dispose. Auto-cancellation. Type-safe.
commonMain.dependencies {
implementation("io.github.vietnguyentuan2019:kmp-event-limiter:1.0.0")
}
[versions]
kmpEventLimiter = "1.0.0"
[libraries]
kmp-event-limiter = { module = "io.github.vietnguyentuan2019:kmp-event-limiter", version.ref = "kmpEventLimiter" }
Then in your build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kmp.event.limiter)
}
}
}
Using Modifier (Recommended):
Button(
onClick = {},
modifier = Modifier.throttleClick(duration = 500.milliseconds) {
submitOrder()
}
) {
Text("Submit Order")
}
Using Direct Controller:
val scope = rememberCoroutineScope()
val throttler = remember { Throttler(scope) }
Button(onClick = throttler.wrap { submitOrder() }) {
Text("Submit Order")
}
var searchQuery by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<Product>>(emptyList()) }
var isSearching by remember { mutableStateOf(false) }
AsyncDebouncedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
onDebouncedChange = { query ->
// This is a suspend function called after 500ms pause
searchApi(query)
},
onSuccess = { results ->
searchResults = results
},
onLoadingChanged = { loading ->
isSearching = loading
},
debounceTime = milliseconds,
trailingIcon = {
(isSearching) CircularProgressIndicator(modifier = Modifier.size(dp))
Icon(Icons.Default.Search, )
}
)
AsyncButton(
onClick = {
// Suspend function automatically manages loading state
uploadFile()
},
onError = { error ->
showSnackbar("Upload failed: ${error.message}")
}
) { isLoading ->
if (isLoading) {
Row {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text("Uploading...")
}
} else {
Text("Upload File")
}
}
val scope = rememberCoroutineScope()
val messageSender = remember {
ConcurrentAsyncThrottler(
scope = scope,
mode = ConcurrencyMode.ENQUEUE
)
}
ConcurrentAsyncButton(
onClick = {
messageSender.call {
sendMessage(messageText)
}
},
mode = ConcurrencyMode.ENQUEUE
) { isLoading, pendingCount ->
Text(
if (pendingCount > 0) "Sending ($pendingCount)..." else "Send"
)
}
val scope = rememberCoroutineScope()
val searchController = remember {
ConcurrentAsyncThrottler(
scope = scope,
mode = ConcurrencyMode.REPLACE
)
}
TextField(
value = searchQuery,
onValueChange = { query ->
searchQuery = query
scope.launch {
searchController.call {
// Old search calls are cancelled
val results = searchApi(query)
searchResults = results
}
}
}
)
val scope = rememberCoroutineScope()
val autoSaver = remember {
ConcurrentAsyncThrottler(
scope = scope,
mode = ConcurrencyMode.KEEP_LATEST
)
}
TextField(
value = documentText,
onValueChange = { text ->
documentText = text
scope.launch {
autoSaver.call {
saveDraft(text) // Only saves current + final version
}
}
}
)
class Throttler(
scope: CoroutineScope,
duration: Duration = 500.milliseconds,
debugMode: Boolean = false,
name: String? = null,
enabled: Boolean = true,
resetOnError: Boolean = false,
onMetrics: ((Duration, Boolean) -> Unit)? = null
)
Methods:
call(callback: () -> Unit) - Execute with throttlewrap(callback: (() -> Unit)?) - Wrap for onClick handlersreset() - Reset throttle statedispose() - Clean up resourcesclass AsyncThrottler(
scope: CoroutineScope,
maxDuration: Duration? = 15.seconds,
// ... same params as Throttler
)
Methods:
suspend fun call(action: suspend () -> Unit) - Execute with async lockfun isLocked(): Boolean - Check if currently lockedfun dispose() - Clean upclass ConcurrentAsyncThrottler(
scope: CoroutineScope,
mode: ConcurrencyMode = ConcurrencyMode.DROP,
maxDuration: Duration? = null,
// ...
)
Methods:
suspend fun call(action: suspend () -> Unit) - Execute with concurrency modefun pendingCount(): Int - Get pending operation countfun dispose() - Clean upfun Modifier.throttleClick(
duration: Duration = 500.milliseconds,
enabled: Boolean = true,
onClick: () -> Unit
): Modifier
fun Modifier.throttleClickable(
duration: Duration = 500.milliseconds,
enabled: = ,
onClick: () ->
): Modifier
fun Modifier.asyncThrottleClick(
maxDuration: Duration? = null,
mode: ConcurrencyMode = ConcurrencyMode.DROP,
enabled: Boolean = true,
onClick: suspend () -> Unit
): Modifier
@Composable
fun AsyncButton(
onClick: suspend () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
mode: = ConcurrencyMode.DROP,
maxDuration: ? = ,
onError: (() -> )? = ,
loadingIndicator: @ () -> = { CircularProgressIndicator() },
content: (isLoading: ) ->
)
@Composable
fun DebouncedTextField(
value: String,
onValueChange: (String) -> Unit,
onDebouncedChange: suspend (String) -> Unit,
modifier: Modifier = Modifier,
debounceTime: Duration = 500.milliseconds,
// ... standard TextField params
)
@Composable
fun <T> AsyncDebouncedTextField(
value: String,
onValueChange: (String) -> ,
onDebouncedChange: () -> ?,
modifier: = Modifier,
onSuccess: (() -> )? = ,
onError: (() -> )? = ,
onLoadingChanged: (() -> )? = ,
)
Fires immediately, then blocks for duration.
User clicks: ▼ ▼ ▼▼▼ ▼
Executes: ✓ X X X ✓
|<-500ms->| |<-500ms->|
Use for: Button clicks, refresh actions, preventing spam
Waits for pause in events, then fires.
User types: a b c d ... (pause) ... e f g
Executes: ✓ ✓
|<--300ms wait-->| |<--300ms wait-->|
Use for: Search input, auto-save, slider changes
If you're familiar with flutter_event_limiter, here's the mapping:
Key Differences:
Near-zero overhead:
| Metric | Performance |
|---|---|
| Throttle/Debounce | ~0.01ms per call |
| AsyncThrottler | ~0.02ms per call |
| Memory | ~40 bytes per controller |
Benchmarked: Handles 1000+ concurrent operations without frame drops.
Contributions are welcome! Please:
See Contributing Guidelines for details.
Copyright 2025 Nguyễn Tuấn Việt (vietnguyentuan2019)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance the License.
You may obtain a copy of the License at
http:
Unless applicable law agreed to writing, software
distributed under the License distributed an BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express implied.
See the License the specific language governing permissions
limitations under the License.
Built with ❤️ for the Kotlin Multiplatform community
Inspired by flutter_event_limiter
| Feature | Description | Use Case |
|---|
| Throttle | Execute immediately, block duplicates | Button clicks, Refresh |
| Debounce | Wait for pause, then execute | Search input, Auto-save |
| AsyncThrottler | Lock during async execution | Form submit, File upload |
| Concurrency Control | 4 modes: Drop, Enqueue, Replace, Keep Latest | Chat, Search, Sync |
| Mode | Behavior | Perfect For |
|---|
| Drop | Ignore new calls while busy | Payment buttons |
| Enqueue | Queue and execute sequentially | Chat messages |
| Replace | Cancel old, start new | Search queries |
| Keep Latest | Run current + latest only | Auto-save drafts |
| Platform | Status | Notes |
|---|
| Android | Full | Min SDK 21 |
| iOS | Full | iOS 13.0+ |
| Desktop | Full | Windows, macOS, Linux |
| Web | Full | Kotlin/Wasm |
| Flutter | KMP Event Limiter |
|---|
ThrottledInkWell | Modifier.throttleClick() |
AsyncThrottledCallbackBuilder | AsyncButton |
AsyncDebouncedTextController | AsyncDebouncedTextField |
ThrottledBuilder | Use Modifiers (more idiomatic) |
ConcurrentAsyncThrottler | ConcurrentAsyncThrottler (same concept) |
Surfaced from shared tags and platforms — no rankings paid for.