Kotlin's delay() Never Blocks a Thread — Here's the Four-Stage Mechanism That Runs It
Misunderstanding delay as a thread-blocking sleep leads developers to wrap it in unnecessary thread-switching or worry about main-thread freezes. Knowing it's a non-blocking suspension mechanism clarifies why structured concurrency on Android stays responsive and how custom dispatchers can hook into any event loop.
When a coroutine hits delay(1000), the current thread is released instantly — no Thread.sleep(), no spin-wait, no blocking. The remaining code is saved as a Continuation and handed to the environment's scheduler. On Android, that means a Handler message with a future timestamp; on the JVM, it falls back to a global DefaultExecutor daemon thread using LockSupport.parkNanos().
Once the timer fires, the task does not resume in-place. It first passes through the coroutine Dispatcher, which re-posts the continuation to the correct target thread — often the main thread on Android. Only then does the state machine advance and execute the code after the delay.
A companion demo rebuilds this entire mechanism from scratch with a custom Looper, Handler, Message queue, and CoroutineDispatcher. The implementation centers on the scheduleResumeAfterDelay function, which wraps the continuation resume inside a Runnable, packages it as a delayed Message, and lets the event loop handle the rest.
Many developers treat delay as a magical black box; exposing it as a plain old scheduling problem demystifies coroutine performance and makes custom dispatcher implementations approachable.
The fact that delay's fallback on the JVM is a single global daemon thread (DefaultExecutor) means thousands of concurrent delays can be managed without a thread-per-delay explosion — a design choice that mirrors how production event loops work.
Building a minimal EventLoop to replicate delay reveals that the core scheduling primitive is just a time-ordered queue and a thread-safe resume callback, which is simpler than most developers expect.