Threads vs. suspend functions
Years of Thread and ExecutorService in Java trained me to think of concurrency as something you manage explicitly: pools, callbacks, careful synchronization. A suspend function reads like ordinary sequential code but doesn't block the thread it's running on.
suspend fun fetchReading(id: String): Reading {
return withContext(Dispatchers.IO) {
api.getReading(id)
}
}
What's genuinely different from CompletableFuture
Java's CompletableFuture chains get the job done, but cancellation is something you wire up yourself. Kotlin's structured concurrency ties a coroutine's lifetime to its scope: cancel the scope, and every child coroutine launched inside it cancels automatically, without extra bookkeeping.
Picking a dispatcher isn't automatic
Dispatchers.IO for blocking calls, Dispatchers.Default for CPU-bound work, Dispatchers.Main for UI updates: none of that gets inferred for you, the way a thread pool's queue depth stays invisible in plain Java. Getting it wrong doesn't crash anything, it just quietly serializes work that should have run in parallel, which is a much harder bug to notice.
Flow made streaming data feel natural
Turning a sequence of sensor readings into a Flow<Reading> and applying debounce, map, and collect operators read closer to reactive stream processing than anything in the Java standard library. Backpressure, cancellation, and error handling all came for free instead of being hand-rolled.
Testing needed a different mental model
Advancing virtual time with runTest and a TestDispatcher replaced the usual pattern of sprinkling Thread.sleep calls through a test and hoping the timing held. Tests that used to be flaky under load became deterministic.
Interop with existing Java code is almost too easy
Calling into an existing Java library from Kotlin worked on the first try, no adapter layer needed. That went smoother than I expected given how different the concurrency model is underneath.
Exception handling had a sharp edge
A checked exception in Java forces you to deal with a failure at the call site, whether you like it or not. Coroutines flipped that: an unhandled exception in a child coroutine cancels its entire parent scope by default, including sibling coroutines that had nothing to do with the failure. SupervisorJob was the fix, letting siblings fail independently, but it's opt-in, and the first time I hit this I lost an unrelated task to a single failed network call before I understood why.
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch { riskyFetch() } // a failure here won't cancel the sibling below
scope.launch { unrelatedWork() }
Channels for the producer/consumer cases
For a sensor publisher feeding a slower consumer, a Channel did what a Java BlockingQueue used to: buffer between two coroutines running at different speeds, with suspension instead of a blocking call when the buffer fills up. The API is smaller than BlockingQueue's, but the suspending backpressure was the part that actually mattered.
The syntax is the easy part to learn. Structured concurrency (cancellation and scope propagating automatically through a call tree) is the part that actually changes how you design concurrent code.