1. Why you can’t call suspend from main
If you’re seeing coroutines for the first time, the whole suspend thing usually causes a bit of surprise. Something like: “But I just want to call a function—why is the compiler against it?” And the compiler, like a strict bouncer at a club, replies: “ID, please. You don’t have a coroutine context—entry denied.”
The point is that suspend changes the function contract. Such a function may suspend, which means it needs a “manager” that can pause a coroutine and then resume it. In Kotlin, that manager appears when you start a coroutine execution block (and today we’ll learn exactly how to do that).
Let’s look at a typical situation and a typical error.
suspend fun loadUserName(): String {
return "Alice"
}
fun main() {
val name = loadUserName() // <-- you can’t do this
println(name)
}
The compiler will say something roughly like this (the wording may differ, but the meaning is the same):
Suspend function 'loadUserName' should be called only from a coroutine or another suspend function.
And that’s great: the compiler prevents you from writing code that would later “accidentally” behave in a weird way. It forces you to clearly mark the boundary: here is where the coroutine scenario begins, and here is where the regular code is.
2. runBlocking as a bridge into coroutines
Now to the main character of this lecture. runBlocking is a function from kotlinx.coroutines that lets you run coroutine code from a regular function. It does two important things at the same time.
First, it creates a coroutine environment in which you can call suspend functions. Second, it blocks the current thread until its block completes. That may sound like “betraying the ideals of a non-blocking future,” but for a console main it’s exactly fine: we want the program not to exit until the scenario has finished. (If main ends earlier, you simply won’t see half the actions.)
The minimal skeleton looks like this:
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("We are inside runBlocking") // We are inside runBlocking
}
Notice the form fun main() = .... This is just a compact notation (expression body).
If you feel more comfortable seeing curly braces on main (for some people it’s easier to read), you can do it like this:
import kotlinx.coroutines.runBlocking
fun main() {
runBlocking {
println("Inside runBlocking again") // Inside runBlocking again
}
}
Both forms are correct. In teaching examples the first is often more convenient, because you can immediately see: the entry point is runBlocking.
3. Adding kotlinx.coroutines
Before writing runBlocking, it’s important to state an obvious (and often forgotten) thing: runBlocking is not in the Kotlin standard library; it lives in kotlinx.coroutines. That means your project must have a coroutines dependency, otherwise the IDE will simply say Unresolved reference: runBlocking.
The logic is: add the dependency in build.gradle.kts, sync the project, import the needed functions. The version depends on your project template and course setup, so here’s the pattern (the idea) without tying it to a specific number.
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:VERSION")
}
Then in code:
import kotlinx.coroutines.runBlocking
And now runBlocking becomes “legal.”
4. runBlocking blocks the current thread
The name runBlocking is honestly a bit depressing: yes, it blocks. That’s why in console applications we use runBlocking as the entry point, not as a “handy thing you can call anywhere.”
To feel this in practice, this example is enough:
import kotlinx.coroutines.runBlocking
fun main() {
println("Before") // Before
runBlocking {
println("Inside") // Inside
}
println("After") // After
}
The execution logic is strictly sequential: Before → Inside → After. Between Before and After, the thread is busy executing the runBlocking block. This is not “parallelism,” not “background,” and not a “secret coroutine that lives separately.” It’s a bridge that holds main in place until the coroutine scenario finishes.
If you want a simple everyday analogy, runBlocking is like a subway turnstile. While you’re inside the turnstile, you can’t “quickly run for coffee and come back”—you pass through to the end first, and only then life continues.
From a console app architecture point of view, this is very convenient: the program should live exactly as long as the main scenario lives.
5. runBlocking as an expression and returning a value
A super practical trick: runBlocking can be used not only to “execute a block,” but also as an expression that returns a value. This lets you build a bridge “there and back”: call a suspend function and get a regular value that then lives in regular code.
Example with a suspend function that returns an Int:
import kotlinx.coroutines.runBlocking
suspend fun answer(): Int {
return 42
}
fun main() {
val x: Int = runBlocking { answer() }
println(x) // 42
}
What’s important here is the idea: runBlocking { ... } is not “special syntax,” but an ordinary function that returns the result of the last line of the block. That is, it’s just like an if expression or a try expression, only with coroutines.
6. One runBlocking in main and a suspend scenario
Now let’s take a step toward a style that really saves beginners from chaos. A typical newbie mistake is to start inserting runBlocking anywhere: “Oh, I need a suspend call here, so I’ll just wrap it in runBlocking.” The problem is that you smear the “regular code ↔ coroutine code” boundary all over the project, and a week later you won’t be able to explain yourself where which logic lives.
It’s better to keep a single entry into the coroutine scenario—in main—and then call suspend functions in a chain.
Let’s do this with a mini practical example of a console application. Imagine we have a learning CLI project (let’s call it BudgetBuddy): it stores expenses and prints reports. Today we still won’t do anything “asynchronous”—we’re just preparing the architecture so that tomorrow we can add proper waiting and parallelism.
First—the models (briefly):
data class Expense(
val amount: Int,
val category: String
)
Now—the coroutine scenario of the application. Important: main is small, and all logic is in suspend fun runApp().
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
runApp()
}
suspend fun runApp() {
println("BudgetBuddy started") // BudgetBuddy started
}
For now it looks like “we moved one line into another function,” but it’s a very correct scaffold. As soon as waiting or suspend API calls appear inside runApp(), you won’t have to restructure the whole project—the contract is already honest.
7. Example: load data and print a report
Now let’s add a bit more “life,” but without topics from the next lecture (we’re not doing delay, not starting launch/async, not choosing Dispatchers). Our goal is to see how runBlocking lets you call suspend functions even if they “don’t suspend yet.”
Let’s make the data loading function suspend (in the future it might read a file/DB/network, but today it just returns a stub):
suspend fun loadInitialExpenses(): List<Expense> {
return listOf(
Expense(amount = 1200, category = "Food"),
Expense(amount = 3500, category = "Rent"),
Expense(amount = 800, category = "Coffee")
)
}
And the function that builds the report string (a regular function, because it just computes a string):
fun formatReport(expenses: List<Expense>): String {
val total = expenses.sumOf { it.amount }
return "Total expenses: $total"
}
Let’s assemble the scenario in runApp():
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
runApp()
}
suspend fun runApp() {
val expenses = loadInitialExpenses()
val report = formatReport(expenses)
println(report) // Total expenses: 5500
}
Check it visually: the code reads almost like “regular sequential” code. And that’s one of the reasons people like coroutines: they let you write a linear, readable scenario while still leaving you the ability to await “long operations” honestly and safely.
8. Execution flow and the style of using runBlocking
Execution diagram
Before moving further into the day, it’s useful to solidify the execution model specifically for the console case. Right now you have this chain: the JVM calls main, main creates a coroutine environment via runBlocking, runs the block, and only then exits the program.
This can be imagined like this:
flowchart TD
A["JVM calls main()"] --> B["main() calls runBlocking { ... }"]
B --> C["Inside the block: suspend scenario runApp()"]
C --> D["The scenario finishes"]
D --> E["runBlocking returns control to main()"]
E --> F["main() finishes, the program ends"]
The meaning of the diagram in one phrase: runBlocking is a “controlled door” into the coroutine scenario, and until the scenario ends, the console program doesn’t close.
Where the usefulness of runBlocking ends
It’s very tempting to “use runBlocking everywhere it’s inconvenient.” But at that exact moment it turns into an anti-pattern. Its strength is a clear entry point, where you have an outer world (regular functions, JVM, main) and an inner world (suspend scenarios).
For a console application, a typical healthy style looks like this: main is a thin wrapper; everything that looks like a “scenario” or a “long operation” is designed as suspend functions called inside a single runBlocking.
It’s also useful to remember that Kotlin can, in principle, have a suspend fun main(), and in official coroutine examples you can see exactly that form, where main itself is suspend. But within our course we stick to runBlocking, because it’s easier to explain, it explicitly shows the blocking semantics, and it fits a console program best as a “bridge.”
9. Common mistakes when working with runBlocking
Mistake #1: thinking runBlocking “doesn’t block” because it’s coroutines.
The name is as honest as it gets: runBlocking blocks the current thread until the block completes. That’s fine in main, but if you start using it inside business logic “just to call suspend,” you bring back the old blocking problem, just in new packaging.
Mistake #2: using many runBlocking calls in different places in the project “for convenience.”
That creates multiple scattered entry points into the coroutine world, and you lose control over where you have regular code and where you have coroutine code. Most often it’s better to have one runBlocking in main, and then build a chain of suspend functions so the contract is consistent and readable.
Mistake #3: trying to call a suspend function from a regular function and “fixing” it with runBlocking right there.
It’s very tempting: “I only need it once.” But then “once” becomes ten times, and then you wonder why the program became slow and why half your functions suddenly depend on coroutines, even though you didn’t plan that.
Mistake #4: mixing everything in main: argument parsing, input/output, and the coroutine scenario in one huge block.
runBlocking doesn’t require you to write a “wall of code.” On the contrary: it lets you neatly wrap the main scenario into a separate function (runApp()) and keep main as a thin wrapper. That makes the code easier to read, test, and extend.
Mistake #5: expecting runBlocking by itself to make the program “asynchronous.”
runBlocking is a way to enter coroutine code. It doesn’t start parallelism automatically and doesn’t make waiting “non-blocking” by magic. It simply gives you a coroutine environment and holds the thread until the scenario completes. Everything else depends on which suspend functions and which coroutine mechanisms you use inside (those are topics for the next lectures).