A deep dive into evolving a naive double-click prevention fix into a robust in-flight deduplication mechanism for Android apps using Kotlin coroutines. The piece traces the journey from time-based debouncing, to tracking a single Job's lifecycle, to a scalable, reusable UniqueJobTracker backed by a ConcurrentHashMap that uses atomic compute operations and CoroutineStart.LAZY to guarantee only one execution of a keyed operation is active at a time, applicable beyond ViewModels to any owner of async work.

12m read timeFrom proandroiddev.com
Post cover image
Table of contents
It Started With a Button ClickThe First Solution: DebouncingNavigation Made the Problem WorseMoving From Time to the Operation’s LifecycleThe Navigation Problem Was Still ThereBut One Job Variable Doesn’t ScaleFrom Button Clicks to Event-Driven BurstsMaking It a Reusable TrackerWhy ConcurrentHashMap.compute MattersGet Abdullahkhan ’s stories in your inboxThe Next Problem: The Job Can Start Too EarlyUsing CoroutineStart.LAZYMoving start() Into the TrackerExtending the Solution Beyond ViewModelsDefining the Operation’s IdentityThe Bigger Idea

Questions this post answers

How do I prevent a coroutine job from starting before I know if it's a duplicate operation?

Use CoroutineStart.LAZY when launching the coroutine so the job exists but its body does not run until explicitly started. This creates a window where a tracker can register the job, check if the key is already owned, cancel it if it is a duplicate, and only call start() if it wins ownership, avoiding partial execution before the ownership decision. daily.dev surfaces patterns like this for developers building safer async job tracking in Kotlin.

Why is ConcurrentHashMap.compute needed instead of a plain containsKey check before inserting a job into a map?

A plain containsKey followed by an insert is not atomic, so two concurrent events can both see an empty slot and both insert their job, defeating deduplication. ConcurrentHashMap.compute makes the check-and-update atomic for a given key, ensuring only one job becomes the owner while the other gets cancelled instead of registered. developers hardening concurrent Kotlin code against race conditions can track patterns like this on daily.dev.

Why does debouncing a button click fail to prevent duplicate async operations in Android?

Debouncing protects the UI for a fixed time window, but the underlying operation's actual duration can exceed that window, letting a second click trigger a duplicate operation after the button re-enables. It also fails when navigation or other side effects complete after the debounce expires, causing duplicate navigation or crashes. Tracking the operation's actual Job lifecycle solves this instead. daily.dev helps developers compare debouncing against job-based tracking when designing reliable async click handling.

1K Impressions