1. suspend and delay: waiting without blocking
When you start programming, waiting seems like the simplest action in the world: “I’ll just pause for 200 ms and that’s it.” But the computer is not meditating at that moment — it’s holding resources. If waiting is done in a blocking way, you occupy a thread and prevent the program from doing anything else (sometimes — even showing the UI or handling input).
Imagine that a thread is a waiter in a cafe. If the waiter walks away and stands by the wall waiting exactly 5 minutes while you finish your soup, then they are not taking new orders and not bringing dishes to other tables. That’s blocking: the thread is busy waiting and provides no value.
Coroutines in Kotlin were invented precisely so that waiting could be expressed differently: a coroutine is “paused”, and the thread at that moment can do other work. In official Kotlin materials this is described as the ability to suspend execution without blocking system resources.
suspend: an honest “I may wait” marker
The word suspend often scares beginners because it looks like a “magic mode”. In reality it’s almost like a sticker on a box: “careful, there may be waiting inside”. suspend does not promise acceleration, does not promise a separate thread, and does not promise parallelism. It promises only one thing: this function is allowed to be suspended (meaning it may contain suspension points).
From this follows an immediate practical rule: if your function does “proper waiting” via coroutine mechanisms, it almost certainly should be suspend. And then the compiler starts protecting you: it won’t allow calling such a function from regular code “however you want”.
Let’s look at a minimal example of a “function with an honest contract”:
import kotlinx.coroutines.delay
suspend fun loadGreeting(): String {
delay(200)
return "Hello!"
}
What matters here is not the string "Hello!", but the fact that delay(200) requires a coroutine context. Which means the function is required to be suspend.
If you try to call it directly from a regular main, the compiler won’t allow it:
fun main() {
val text = loadGreeting() // Compilation error: suspend function...
println(text)
}
This is not “Kotlin being mean”, but protection from unpredictable waiting in the wrong place.
delay(...): a “pause” without blocking a thread
Now let’s move on to the hero of the lecture — delay. At the level of how it feels, delay(200) is similar to Thread.sleep(200): “wait 200 milliseconds”. But their meaning is different. delay is a suspend function from kotlinx.coroutines that suspends a coroutine, rather than blocking a thread. That’s why it can only be called inside coroutine code.
The simplest scaffold (which we already had in the lecture about runBlocking) looks like this:
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("A") // A
delay(200)
println("B") // B
}
The key point for a beginner: yes, visually the program “stands still” between "A" and "B". But this waiting is expressed in a way that lets the coroutine yield execution rather than holding the thread “hostage”.
Suspension point: where a coroutine is allowed to “pause”
delay(...) is a classic example of a suspension point. It’s a place where a coroutine can stop its execution and continue later. In Kotlin documentation, coroutines are described this way: code can be suspended and resumed in a natural sequential style via suspending functions.
A small diagram for intuition:
flowchart TD
A["Coroutine executes code"] --> B["Encounters delay(...)"]
B --> C["Coroutine is suspended"]
C --> D["Time passes"]
D --> E["Coroutine continues execution after delay"]
Important: this diagram is about the coroutine, not the thread. A thread is a separate entity, and it can be free to do other things while the coroutine “sleeps”.
3. Thread.sleep(...): a pause that truly “freezes” a thread
Now let’s compare with a Java classic — Thread.sleep. This function does exactly what it says: it puts the current thread to sleep. That is, the waiter actually goes into the back room and locks the door with a key.
And here a typical trap appears: “But I’m inside runBlocking, so it’s coroutines, so Thread.sleep is kind of ‘ok’.” No. Coroutines do not cancel physics. If you call Thread.sleep, the thread is blocked for real.
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("Before sleep") // Before sleep
Thread.sleep(200)
println("After sleep") // After sleep
}
From the outside the behavior is similar to delay, but semantically it’s completely different. delay means “the coroutine will wait”, Thread.sleep means “the thread will wait”.
To lock in the difference, a small table is useful:
| What we do | How we wait | What gets “paused” | Need suspend | Can it be called from a regular function |
|---|---|---|---|---|
| Pause in coroutine code | |
Coroutine | Yes | No |
| Pause anywhere | |
Thread | No | Yes |
4. Practical pattern: waiting inside a suspend function
In this section we’ll take the most useful step: not just look at delay, but embed it into a programming style that doesn’t fall apart after a week. Courses often show delay(100) right in main, but real projects don’t live like that. A good style is to encapsulate “long operations” into separate functions and make their contract honest.
Suppose we have our console mini-project (we were building it recently in the lecture about collections and commands): simple expense tracking with commands add, list, total. Now we want to add a sync command that “pretends” to synchronize data with a server. Our server is imaginary for now, so we’ll simulate latency.
Let’s make a suspend function:
import kotlinx.coroutines.delay
suspend fun syncExpenses(): String {
delay(500)
return "Sync completed"
}
And now we use it in a coroutine scenario via runBlocking:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val result = syncExpenses()
println(result) // Sync completed
}
This is a very important architectural habit: you don’t smear waiting across the whole codebase; you keep it where it logically belongs to the operation.
For comparison: a “blocking version” of the same operation
Sometimes it’s useful to make an analogue so your brain notices the difference more clearly:
fun syncExpensesBlocking(): String {
Thread.sleep(500)
return "Sync completed (blocking)"
}
And the call:
fun main() {
val result = syncExpensesBlocking()
println(result) // Sync completed (blocking)
}
Externally it looks similar. But the first version fits into the coroutine world and plays nicely with other suspend operations, while the second is always about blocking a thread.
5. Why delay doesn’t “speed things up”, and how to debug waiting
Why delay doesn’t “speed up the program”, but is still the correct choice
This section often triggers an internal argument: “If I have a console program with a single main, and I’m just waiting 500 ms, what’s the difference — delay or Thread.sleep? Either way 500 ms will pass.” And that’s a good question: in a minimal example it really does seem like there’s no difference. At the level of time “between A and B” — yes, it’s the same.
The difference shows up when the program becomes more complex and contains more work than one linear sequence. Then blocking the thread starts to get in the way: the thread is busy waiting and cannot serve other tasks. Coroutines, on the contrary, are designed as lightweight units of execution that can be suspended without blocking system resources.
It’s important to phrase this carefully without jumping ahead: delay doesn’t do magic by itself; it simply makes waiting “compatible” with the coroutine model. Even if right now you’re not running multiple coroutines, you’re already writing code so it won’t become a “blocking brick” in the future.
One more practical reason to like delay: it forces you to keep waiting in the suspend layer. And that automatically makes the code cleaner: you can tell from the function signature that there may be waiting inside.
A tiny debugging skill: “check who waits and how”
In this section we’ll practice understanding waiting not only with our eyes, but with numbers. When you start writing coroutine code, you’ll often have the feeling “it seems to work, but it’s kind of mysterious”. The simplest way to remove the mystery is to measure time and print intermediate steps.
Let’s make a utility that prints elapsed time (this isn’t specific to coroutines, but it helps a lot):
fun log(startMs: Long, message: String) {
val delta = System.currentTimeMillis() - startMs
println("[+$delta ms] $message")
}
Now use it with delay:
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val start = System.currentTimeMillis()
log(start, "Started") // [+0 ms] Started
delay(200)
log(start, "After delay") // [+200..250 ms] After delay
}
And with Thread.sleep:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val start = System.currentTimeMillis()
log(start, "Started") // [+0 ms] Started
Thread.sleep(200)
log(start, "After sleep") // [+200..250 ms] After sleep
}
Again: externally it looks similar. But now you clearly see where the waiting is, and you can start moving it in a disciplined way into suspend functions.
6. Common mistakes when working with suspend, delay, and Thread.sleep
Mistake #1: trying to call delay() from a regular function and “somehow bypass the compiler”.
Beginners sometimes start looking for workarounds: “can I do delay without suspend?”. Technically you can (via bad tricks), but you break the core point of coroutines: the waiting contract must be visible in the signature. The correct fix is almost always the same: either mark the function as suspend, or move the waiting to a place where there is already a coroutine context (for example, inside a runBlocking block).
Mistake #2: using Thread.sleep() inside coroutine code “because it’s simpler”.
This is the most common habit from Java: slap in Thread.sleep and move on. The problem is that this blocks the thread. While you’re learning, it may not explode, but as soon as there are many waits, or other tasks appear, the program will start “lagging” in a completely non-obvious way. If you want to actually “wait” in a coroutine scenario — use delay.
Mistake #3: adding suspend “just in case”, without being sure why.
Sometimes students start putting suspend everywhere, as if it were “coroutine code mode”. But suspend is not decoration and not an accelerator; it changes the function contract: it can’t be called directly from regular code. A good criterion is simple: add suspend when there is actually delay inside the function (or other suspend operations), or when you consciously want to allow it to be suspended.
Mistake #4: expecting delay by itself to make execution parallel.
delay is waiting, not launching additional work. If your program does one operation after another, it will keep doing one after another — just with waiting expressed correctly. That’s fine: right now we’re learning “how to wait correctly”, not “how to do everything at once”.
Mistake #5: mixing blocking and non-blocking waiting in the same logic without clear boundaries.
When in one place you have delay, in another — Thread.sleep, and around that there’s input/output and commands, the behavior becomes hard to predict. It’s better to stick to a discipline: if you’re already in a coroutine scenario, use coroutine waiting tools; if you’re writing fully regular synchronous code — then at least be consciously aware that Thread.sleep blocks the thread.
GO TO FULL VERSION