
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)
}
}
}| 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 |
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 = 500.milliseconds,
trailingIcon = {
if (isSearching) CircularProgressIndicator(modifier = Modifier.size(24.dp))
else Icon(Icons.Default.Search, "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: Boolean = true,
onClick: () -> Unit
): Modifier // With ripple effectfun 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 = ConcurrencyMode.DROP,
maxDuration: Duration? = null,
onError: ((Throwable) -> Unit)? = null,
loadingIndicator: @Composable () -> Unit = { CircularProgressIndicator() },
content: @Composable (isLoading: Boolean) -> Unit
)@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) -> Unit,
onDebouncedChange: suspend (String) -> T?,
modifier: Modifier = Modifier,
onSuccess: ((T) -> Unit)? = null,
onError: ((Throwable) -> Unit)? = null,
onLoadingChanged: ((Boolean) -> Unit)? = null,
// ...
)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
| Platform | Status | Notes |
|---|---|---|
| Android | Full | Min SDK 21 |
| iOS | Full | iOS 13.0+ |
| Desktop | Full | Windows, macOS, Linux |
| Web | Full | Kotlin/Wasm |
If you're familiar with flutter_event_limiter, here's the mapping:
| Flutter | KMP Event Limiter |
|---|---|
ThrottledInkWell |
Modifier.throttleClick() |
AsyncThrottledCallbackBuilder |
AsyncButton |
AsyncDebouncedTextController |
AsyncDebouncedTextField |
ThrottledBuilder |
Use Modifiers (more idiomatic) |
ConcurrentAsyncThrottler |
ConcurrentAsyncThrottler (same concept) |
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 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.
Built with ❤️ for the Kotlin Multiplatform community
Inspired by flutter_event_limiter
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)
}
}
}| 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 |
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 = 500.milliseconds,
trailingIcon = {
if (isSearching) CircularProgressIndicator(modifier = Modifier.size(24.dp))
else Icon(Icons.Default.Search, "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: Boolean = true,
onClick: () -> Unit
): Modifier // With ripple effectfun 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 = ConcurrencyMode.DROP,
maxDuration: Duration? = null,
onError: ((Throwable) -> Unit)? = null,
loadingIndicator: @Composable () -> Unit = { CircularProgressIndicator() },
content: @Composable (isLoading: Boolean) -> Unit
)@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) -> Unit,
onDebouncedChange: suspend (String) -> T?,
modifier: Modifier = Modifier,
onSuccess: ((T) -> Unit)? = null,
onError: ((Throwable) -> Unit)? = null,
onLoadingChanged: ((Boolean) -> Unit)? = null,
// ...
)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
| Platform | Status | Notes |
|---|---|---|
| Android | Full | Min SDK 21 |
| iOS | Full | iOS 13.0+ |
| Desktop | Full | Windows, macOS, Linux |
| Web | Full | Kotlin/Wasm |
If you're familiar with flutter_event_limiter, here's the mapping:
| Flutter | KMP Event Limiter |
|---|---|
ThrottledInkWell |
Modifier.throttleClick() |
AsyncThrottledCallbackBuilder |
AsyncButton |
AsyncDebouncedTextController |
AsyncDebouncedTextField |
ThrottledBuilder |
Use Modifiers (more idiomatic) |
ConcurrentAsyncThrottler |
ConcurrentAsyncThrottler (same concept) |
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 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.
Built with ❤️ for the Kotlin Multiplatform community
Inspired by flutter_event_limiter