Java Threads vs Kotlin Coroutines

Build a concurrent data fetcher three ways: platform threads, virtual threads, and coroutines.

Introduction

30 Second Summary

Imagine ordering food, checking your email, and texting a friend all at the same time instead of doing each one after the other. That is exactly what concurrent programming lets your code do.

In this project, you will build a data fetcher that grabs information from multiple services simultaneously. You will implement it three different ways to understand why Kotlin coroutines are the modern standard for concurrent code on the JVM.

What You'll Build

A User Profile Aggregator that fetches data from three simulated services concurrently, implemented with platform threads, virtual threads, and Kotlin coroutines in a single project.

By the end of this project, you'll have:

  • A working Gradle Kotlin/JVM project configured with JDK 21 and kotlinx-coroutines.
  • Three implementation files demonstrating platform threads, virtual threads, and coroutines side by side.
  • A main runner that executes all three approaches and prints a timing comparison proving they all complete concurrently.
  • Secret Mission: Add timeout handling and automatic cancellation to demonstrate structured concurrency in action.

Are there any prerequisites?

You should be familiar with basic Kotlin syntax (variables, functions, classes) and have used IntelliJ IDEA before. No prior experience with threads or concurrency is required.

Set Up Your Kotlin/JVM Project with JDK 21

Before you can build anything concurrent, you need a Kotlin project that targets JDK 21. JDK 21 is the version that introduced virtual threads as a stable feature, which you will compare against coroutines later in this project.

In this step, you will create a Gradle project in IntelliJ IDEA, download JDK 21 through IntelliJ's built-in JDK manager, and add the Kotlin coroutines library as a dependency.

In this step, get ready to:
  • Create a Kotlin/JVM Gradle project in IntelliJ IDEA.
  • Download and configure JDK 21 using IntelliJ's JDK manager.
  • Add the coroutines dependency and verify the project builds.
Create a new Kotlin/JVM Gradle project

IntelliJ IDEA's New Project wizard lets you configure the language, build system, and JDK in one place. You will download JDK 21 directly from this wizard.

  • In IntelliJ IDEA, click New Project on the Welcome screen.
  • In the Name field, enter concurrent-data-fetcher.
  • Set Language to Kotlin.
  • Set Build system to Gradle.
  • Set Gradle DSL to Kotlin.
  • Now look at the JDK dropdown. This is where you will get JDK 21.

✔️ I see JDK 21 in the list

  • Select the JDK 21 option from the dropdown.
  • You are all set. Skip ahead to clicking Create below.

ⓧ I don't see JDK 21

  • Click the JDK dropdown and select Download JDK.
  • In the dialog that appears, set Version to 21.
  • Leave the vendor at its default (any vendor providing JDK 21 will work).
  • Click Download.
  • Wait for the download to complete. IntelliJ will automatically select the newly downloaded JDK.

Download stuck or failing?

Make sure you have an active internet connection. If the download stalls, cancel it, close the dialog, then reopen the JDK dropdown and try Download JDK again.

Help me download JDK 21 in IntelliJ IDEA

  • With JDK 21 selected, click Create.

IntelliJ will generate the project and run an initial Gradle sync. Wait for the progress bar at the bottom of the window to finish.

What just happened?

IntelliJ created a Kotlin project with Gradle as the build tool. Gradle is a build automation system that manages your dependencies, compiles your code, and runs your tests.

The project already has a build.gradle.kts file (the Kotlin DSL build script) and the standard src/main/kotlin source directory.

Configure your build file with coroutines

The generated build file is almost ready, but it does not include the coroutines library yet. You need to add the kotlinx-coroutines-core dependency and explicitly set the JVM toolchain to 21.

  • In the Project tool window on the left, expand the project root and double-click build.gradle.kts to open it.
  • Replace the entire contents of the file with the following:
plugins {
    kotlin("jvm") version "2.4.0"
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

kotlin {
    jvmToolchain(21)
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
    testImplementation(kotlin("test"))
}

tasks.test {
    useJUnitPlatform()
}

What does this code do?

  • kotlin("jvm") version "2.4.0" applies the Kotlin JVM compiler plugin at version 2.4.0.
  • jvmToolchain(21) tells Gradle to compile all code targeting JDK 21. Gradle will automatically use the JDK you downloaded.
  • The kotlinx-coroutines-core dependency (version 1.11.0) gives you access to coroutine builders like async, launch, and delay.
  • After pasting, IntelliJ shows a banner at the top of the editor. Click Load Gradle Changes to sync the project with your updated build file.
  • Wait for the sync to complete. The progress bar at the bottom of the window will disappear when it finishes.

Seeing sync errors?

If Gradle reports an error about the JDK version, confirm that JDK 21 is selected. Go to File > Project Structure > SDKs and check that a JDK 21 entry exists. If not, click the + button and select Download JDK to get version 21.

Help me fix Gradle sync errors in my Kotlin project

Verify the project builds with Hello World

A quick Hello World confirms that the Kotlin compiler, Gradle, and JDK 21 are all working together.

  • In the Project tool window, expand src > main > kotlin.
  • Right-click the kotlin folder and select New > Kotlin Class/File.
  • Type Main as the name.
  • Select File from the kind dropdown (not Class or Interface).
  • Replace any generated content with the following:
fun main() {
    println("Hello from Kotlin with JDK ${System.getProperty("java.version")}!")
}

What does this code do?

This is a minimal Kotlin entry point. It prints a greeting along with the JDK version the program is running on. If everything is configured correctly, you will see 21 in the output.

  • Click the green play icon (▶) in the gutter next to fun main() and select Run 'MainKt'.
  • Look at the Run panel at the bottom of IntelliJ. You should see output containing Hello from Kotlin with JDK 21 and a BUILD SUCCESSFUL message from Gradle.

Don't see the green play icon?

If the gutter has no run icon, Gradle may still be syncing. Wait for the progress bar at the bottom to disappear, then reopen the file.

If the file has red underlines, make sure you selected File (not Class) when creating it. A top-level fun main() must live in a file, not inside a class.

Help me run my Kotlin main function in IntelliJ

✔️ Awesome, I've got everything!

Great. Make sure you have saved build.gradle.kts and Main.kt.

ⓧ I'd like to double check the full code

Here is the complete build.gradle.kts you should have at this point:

plugins {
    kotlin("jvm") version "2.4.0"
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

kotlin {
    jvmToolchain(21)
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
    testImplementation(kotlin("test"))
}

tasks.test {
    useJUnitPlatform()
}

And here is Main.kt (this is temporary and will be replaced in a later step):

fun main() {
    println("Hello from Kotlin with JDK ${System.getProperty("java.version")}!")
}

Your project is ready to go. Next up, you will build the simulated services and write your first concurrent implementation using platform threads.

Build Simulated Services and the Platform Thread Implementation

Your project compiles and your toolchain is ready. Now it is time to build the core of your concurrent data fetcher.

In the real world, apps often need to fetch data from multiple services at the same time. Waiting for each one sequentially is slow. In this step, you will create simulated services that mimic network latency, then use platform threads to call them concurrently.

In this step, get ready to:
  • Create simulated service functions that mimic network calls with artificial delays.
  • Build a platform thread fetcher that calls all three services concurrently.
  • Run the fetcher and observe concurrent execution in your console output.
Create simulated service functions

Before you can fetch data concurrently, you need something to fetch. These service functions simulate calling downstream APIs by sleeping for a set duration, then returning mock data.

  • In the Project tool window on the left side of IntelliJ, right-click the src/main/kotlin folder.
  • Select New, then Package.
  • Type services and press Enter.
  • Right-click the new services package, select New, then Kotlin Class/File.
  • Select File, type SimulatedServices, and press Enter.
  • Paste the following code into SimulatedServices.kt, replacing any existing content:
package services

object SimulatedServices {

    fun fetchUserDetails(userId: String): String {
        Thread.sleep(200)
        return "User(id=$userId, name=Alice, email=alice@example.com)"
    }

    fun fetchOrders(userId: String): String {
        Thread.sleep(300)
        return "Orders(userId=$userId, count=5, lastOrder=2026-06-15)"
    }

    fun fetchPreferences(userId: String): String {
        Thread.sleep(150)
        return "Preferences(userId=$userId, theme=dark, notifications=true)"
    }
}

What does this code do?

Each function simulates a network call to a different service. Thread.sleep() pauses the current thread for the specified milliseconds, mimicking real-world latency.

The object keyword makes SimulatedServices a singleton. There is only ever one instance, so you can call its functions directly without creating an object.

  • Save SimulatedServices.kt with Cmd+S (macOS) or Ctrl+S (Windows).

Seeing a red underline on the package name?

Make sure the file is physically located inside src/main/kotlin/services/. If you created it in the wrong directory, the package services declaration will not match the folder structure.

Help me fix this package error

Implement the platform thread fetcher

Platform threads are the traditional concurrency model in Java. Each thread maps one-to-one to an operating system thread, which means the OS schedules and manages them. They work, but each one costs roughly 1MB of memory.

  • Right-click the src/main/kotlin folder (not the services package), select New, then Kotlin Class/File.
  • Select File, type PlatformThreadFetcher, and press Enter.
  • Paste the following code into PlatformThreadFetcher.kt, replacing any existing content:
import services.SimulatedServices

fun fetchWithPlatformThreads(userId: String): List<String> {
    val results = mutableListOf<String>()
    val lock = Any()

    val thread1 = Thread.ofPlatform().name("platform-user").start {
        val result = SimulatedServices.fetchUserDetails(userId)
        synchronized(lock) { results.add(result) }
        println("  [Platform] fetchUserDetails on thread: ${Thread.currentThread().name}")
    }

    val thread2 = Thread.ofPlatform().name("platform-orders").start {
        val result = SimulatedServices.fetchOrders(userId)
        synchronized(lock) { results.add(result) }
        println("  [Platform] fetchOrders on thread: ${Thread.currentThread().name}")
    }

    val thread3 = Thread.ofPlatform().name("platform-prefs").start {
        val result = SimulatedServices.fetchPreferences(userId)
        synchronized(lock) { results.add(result) }
        println("  [Platform] fetchPreferences on thread: ${Thread.currentThread().name}")
    }

    thread1.join()
    thread2.join()
    thread3.join()

    return results
}

What does this code do?

Thread.ofPlatform() is a Java 21 builder API that creates a traditional OS-level thread. The .name() call gives each thread a human-readable identity, and .start {} launches it immediately with the given task.

The synchronized(lock) block ensures that only one thread can add to the results list at a time. Without it, two threads could try to write simultaneously and corrupt the list.

The three .join() calls at the bottom make the main thread wait until all three worker threads finish before returning the results.

Why synchronized instead of a concurrent collection?

You could use a thread-safe collection like ConcurrentLinkedQueue, but synchronized makes the thread safety explicit and visible. When you reach the coroutines version later, you will see how structured concurrency eliminates the need for manual synchronization entirely.

  • Save PlatformThreadFetcher.kt with Cmd+S (macOS) or Ctrl+S (Windows).

Red underline on Thread.ofPlatform()?

This method requires JDK 21 or higher. Open File then Project Structure and confirm that your Project SDK is set to version 21. Also check that jvmToolchain(21) is present in your build.gradle.kts.

Help me fix this JDK version issue

Run the platform thread implementation

You now have the services and the fetcher. To see concurrency in action, you need a main function that calls your fetcher and measures how long it takes.

  • Right-click the src/main/kotlin folder, select New, then Kotlin Class/File.
  • Select File, type Main, and press Enter.
  • Paste the following code into Main.kt, replacing any existing content:
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val userId = "user-42"

    println("=== Concurrent Data Fetcher: Three Approaches ===")
    println()

    // 1. Platform Threads
    println("--- Approach 1: Platform Threads ---")
    val platformStart = System.currentTimeMillis()
    val platformResults = fetchWithPlatformThreads(userId)
    val platformTime = System.currentTimeMillis() - platformStart
    println("  Results: $platformResults")
    println("  Time: ${platformTime}ms")
    println()
}

What does this code do?

runBlocking creates a coroutine scope for the main function. You do not need coroutines yet for platform threads, but this structure prepares Main.kt for the virtual thread and coroutine implementations you will add in later steps.

System.currentTimeMillis() captures timestamps before and after the fetch, so you can measure how long all three service calls took when running concurrently.

  • Save Main.kt.
  • Click the green play button (▶) in the gutter next to fun main() and select Run 'MainKt'.

The Run panel at the bottom of IntelliJ displays your console output. You should see three thread names printed (platform-user, platform-orders, platform-prefs) and a total time of approximately 300ms.

Why approximately 300ms and not 650ms?

If the calls ran one after another (sequentially), the total would be 200 + 300 + 150 = 650ms. Because all three threads run at the same time, the total is only as long as the slowest one (300ms for the orders service). This is the whole point of concurrency.

Seeing a time much higher than 300ms?

The first run in IntelliJ may include JVM startup overhead. Run the program a second time using the green play button in the top toolbar. The second run typically shows timing closer to 300ms.

Help me diagnose my timing issue

✔️ Awesome, I've got everything!

Great. Double check you have saved both SimulatedServices.kt and PlatformThreadFetcher.kt.

ⓧ I'd like to double check the full code

Here is the full code for each file at this point.

package services

object SimulatedServices {

    fun fetchUserDetails(userId: String): String {
        Thread.sleep(200)
        return "User(id=$userId, name=Alice, email=alice@example.com)"
    }

    fun fetchOrders(userId: String): String {
        Thread.sleep(300)
        return "Orders(userId=$userId, count=5, lastOrder=2026-06-15)"
    }

    fun fetchPreferences(userId: String): String {
        Thread.sleep(150)
        return "Preferences(userId=$userId, theme=dark, notifications=true)"
    }
}
import services.SimulatedServices

fun fetchWithPlatformThreads(userId: String): List<String> {
    val results = mutableListOf<String>()
    val lock = Any()

    val thread1 = Thread.ofPlatform().name("platform-user").start {
        val result = SimulatedServices.fetchUserDetails(userId)
        synchronized(lock) { results.add(result) }
        println("  [Platform] fetchUserDetails on thread: ${Thread.currentThread().name}")
    }

    val thread2 = Thread.ofPlatform().name("platform-orders").start {
        val result = SimulatedServices.fetchOrders(userId)
        synchronized(lock) { results.add(result) }
        println("  [Platform] fetchOrders on thread: ${Thread.currentThread().name}")
    }

    val thread3 = Thread.ofPlatform().name("platform-prefs").start {
        val result = SimulatedServices.fetchPreferences(userId)
        synchronized(lock) { results.add(result) }
        println("  [Platform] fetchPreferences on thread: ${Thread.currentThread().name}")
    }

    thread1.join()
    thread2.join()
    thread3.join()

    return results
}
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val userId = "user-42"

    println("=== Concurrent Data Fetcher: Three Approaches ===")
    println()

    // 1. Platform Threads
    println("--- Approach 1: Platform Threads ---")
    val platformStart = System.currentTimeMillis()
    val platformResults = fetchWithPlatformThreads(userId)
    val platformTime = System.currentTimeMillis() - platformStart
    println("  Results: $platformResults")
    println("  Time: ${platformTime}ms")
    println()
}

You have just proven that three platform threads run concurrently and complete in the time of the slowest call. Next up, you will rebuild this same fetcher using Java 21 virtual threads and see what changes when threads become lightweight.

Implement the Virtual Thread Version

Your platform thread implementation works, but each of those three threads consumed real OS resources (approximately 1MB of memory per thread stack). Java 21 introduced virtual threads as a lightweight alternative that the JVM manages internally.

In this step, you'll rebuild the same concurrent fetcher using virtual threads. The code looks almost identical to the platform thread version, but each thread costs only about 1-2KB instead of 1MB.

In this step, get ready to:
  • Implement fetchWithVirtualThreads() using a virtual thread executor.
  • Confirm the threads are virtual using isVirtual.
  • Run both implementations back-to-back and compare the output.
Implement the virtual thread executor function

Instead of manually creating and joining threads, virtual threads use an ExecutorService that spawns a new virtual thread for each submitted task. You submit work as lambdas and collect results through Future objects.

  • Create a new file called VirtualThreadFetcher.kt in src/main/kotlin (at the same level as PlatformThreadFetcher.kt).
  • Add the following code to implement fetchWithVirtualThreads():
import services.SimulatedServices
import java.util.concurrent.Executors

fun fetchWithVirtualThreads(userId: String): List<String> {
    // Create an executor that spawns a new virtual thread per task
    Executors.newVirtualThreadPerTaskExecutor().use { executor ->
        // Submit each service call as a separate task
        val future1 = executor.submit<String> {
            val result = SimulatedServices.fetchUserDetails(userId)
            println("  [Virtual] fetchUserDetails on thread: ${Thread.currentThread().name}, isVirtual=${Thread.currentThread().isVirtual}")
            result
        }

        val future2 = executor.submit<String> {
            val result = SimulatedServices.fetchOrders(userId)
            println("  [Virtual] fetchOrders on thread: ${Thread.currentThread().name}, isVirtual=${Thread.currentThread().isVirtual}")
            result
        }

        val future3 = executor.submit<String> {
            val result = SimulatedServices.fetchPreferences(userId)
            println("  [Virtual] fetchPreferences on thread: ${Thread.currentThread().name}, isVirtual=${Thread.currentThread().isVirtual}")
            result
        }

        // Collect results by waiting on each future
        return listOf(future1.get(), future2.get(), future3.get())
    }
}

What does this code do?

Executors.newVirtualThreadPerTaskExecutor() creates an executor that spawns a fresh virtual thread for every submitted task. There is no thread pool here. Each task gets its own virtual thread, but these threads cost only about 1-2KB each instead of the 1MB a platform thread requires.

The .use { } block is Kotlin's equivalent of Java's try-with-resources. When the block finishes, the executor's close() method is called automatically, which waits for all submitted tasks to complete before proceeding.

Each executor.submit<String> { } returns a Future<String> handle. Calling .get() on a future blocks until that task's result is ready.

  • Save VirtualThreadFetcher.kt.

Seeing unresolved reference errors?

Make sure your import statements at the top include both import services.SimulatedServices and import java.util.concurrent.Executors.

If IntelliJ shows newVirtualThreadPerTaskExecutor as unresolved, confirm your project is using JDK 21 in File > Project Structure > Project SDK. This method was introduced in Java 21.

help me fix unresolved reference errors in my VirtualThreadFetcher.kt

Verify threads are virtual with isVirtual

Notice the Thread.currentThread().isVirtual call inside each submit block. This is a JDK 21 property that returns true when the current thread is a virtual thread.

What is a virtual thread?

A virtual thread is a lightweight thread managed by the JVM, not the operating system. The OS never sees virtual threads. Instead, the JVM multiplexes many virtual threads onto a small number of real OS threads (called carrier threads).

This is why isVirtual matters for our comparison. Platform threads report isVirtual = false and have names you chose (like platform-user). Virtual threads report isVirtual = true and have auto-generated names (like an empty string or a numeric ID).

Run both implementations and compare output

To see both approaches side-by-side, you need a main() function that calls both fetchers and measures their timing.

  • Open Main.kt in src/main/kotlin (create it if it does not exist yet).
  • Replace its contents with the following code to run both implementations:
fun main() {
    val userId = "user-42"

    println("=== Concurrent Data Fetcher: Two Approaches ===")
    println()

    // 1. Platform Threads
    println("--- Approach 1: Platform Threads ---")
    val platformStart = System.currentTimeMillis()
    val platformResults = fetchWithPlatformThreads(userId)
    val platformTime = System.currentTimeMillis() - platformStart
    println("  Results: $platformResults")
    println("  Time: ${platformTime}ms")
    println()

    // 2. Virtual Threads
    println("--- Approach 2: Virtual Threads (Java 21) ---")
    val virtualStart = System.currentTimeMillis()
    val virtualResults = fetchWithVirtualThreads(userId)
    val virtualTime = System.currentTimeMillis() - virtualStart
    println("  Results: $virtualResults")
    println("  Time: ${virtualTime}ms")
}

What does this code do?

This main function calls both fetcher implementations one after another, wrapping each in a timing measurement using System.currentTimeMillis(). You should see both complete in approximately 300ms (the longest single service call), proving they run concurrently.

The difference is in the thread names and isVirtual flag. Platform threads show your chosen names (platform-user), while virtual threads show JVM-assigned identifiers.

  • Save Main.kt.
  • Click the green play button next to fun main() in the gutter, or press Ctrl+Shift+R (macOS) or Ctrl+Shift+F10 (Windows) to run.

You should see output showing both approaches completing in roughly 300ms. The virtual thread section prints isVirtual=true for all three tasks.

Both timings look the same?

That is expected. Both approaches run three tasks concurrently, so total time equals the slowest task (about 300ms). The difference is not speed for three tasks. It is resource cost at scale. Platform threads use approximately 1MB each while virtual threads use approximately 1-2KB each.

If you see times significantly higher than 300ms, make sure no other heavy processes are running on your machine. First-run JVM warmup can also add a small delay.

help me understand why my virtual thread timing is different from expected

Why virtual threads instead of a thread pool?

A traditional ExecutorService with a fixed thread pool reuses a small set of OS threads. That saves creation cost, but still limits concurrency to the pool size.

Virtual threads give you the best of both worlds. They are as cheap to create as submitting to a pool, but you get a dedicated thread per task with no pool-size ceiling. In the next step, you will see how Kotlin coroutines take this even further with structured concurrency.

✔️ Awesome, I've got everything!

Great. Double check you have saved VirtualThreadFetcher.kt and confirm your console output shows isVirtual=true for all three virtual thread tasks.

ⓧ I'd like to double check the full code

import services.SimulatedServices
import java.util.concurrent.Executors

fun fetchWithVirtualThreads(userId: String): List<String> {
    Executors.newVirtualThreadPerTaskExecutor().use { executor ->
        val future1 = executor.submit<String> {
            val result = SimulatedServices.fetchUserDetails(userId)
            println("  [Virtual] fetchUserDetails on thread: ${Thread.currentThread().name}, isVirtual=${Thread.currentThread().isVirtual}")
            result
        }

        val future2 = executor.submit<String> {
            val result = SimulatedServices.fetchOrders(userId)
            println("  [Virtual] fetchOrders on thread: ${Thread.currentThread().name}, isVirtual=${Thread.currentThread().isVirtual}")
            result
        }

        val future3 = executor.submit<String> {
            val result = SimulatedServices.fetchPreferences(userId)
            println("  [Virtual] fetchPreferences on thread: ${Thread.currentThread().name}, isVirtual=${Thread.currentThread().isVirtual}")
            result
        }

        return listOf(future1.get(), future2.get(), future3.get())
    }
}

Your virtual thread implementation is working. Next up, you will rebuild this fetcher a third time using Kotlin coroutines, which add structured concurrency on top of the lightweight execution model.

Implement the Kotlin Coroutines Version with Structured Concurrency

You now have two concurrent implementations running side by side. Platform threads create real OS-level resources. Virtual threads reduce that cost.

But both approaches share a weakness: if one task fails, the others keep running. There is no parent-child relationship between them. Kotlin coroutines solve this with structured concurrency. A parent coroutine automatically cancels all its children when one fails, giving you lightweight execution AND lifecycle management in one package.

In this step, get ready to:
  • Create coroutine-friendly service functions that suspend without blocking threads.
  • Implement a coroutine-based fetcher with structured concurrency and error handling.
  • Build a main runner that compares all three approaches with timing output.
Create suspend service functions using delay

Your existing SimulatedServices.kt uses Thread.sleep() to simulate latency. This blocks the entire thread while waiting.

Coroutines use delay() instead, which suspends the coroutine without blocking the underlying thread. The thread is free to run other coroutines while one is waiting.

  • Create a new file called SuspendingServices.kt inside src/main/kotlin/services/ by right-clicking the services package in the Project tool window, selecting New, then Kotlin Class/File.
  • Select File from the kind dropdown, name it SuspendingServices, and press Enter.
  • Replace the file contents with this code:
package services

import kotlinx.coroutines.delay

object SuspendingServices {

    suspend fun fetchUserDetails(userId: String): String {
        delay(200)
        return "User(id=$userId, name=Alice, email=alice@example.com)"
    }

    suspend fun fetchOrders(userId: String): String {
        delay(300)
        return "Orders(userId=$userId, count=5, lastOrder=2026-06-15)"
    }

    suspend fun fetchPreferences(userId: String): String {
        delay(150)
        return "Preferences(userId=$userId, theme=dark, notifications=true)"
    }
}

What does this code do?

Each function is marked suspend, which tells Kotlin this function can pause and resume without blocking a thread.

The delay() call suspends the coroutine for the specified milliseconds. Unlike Thread.sleep(), the thread itself is free to do other work during the wait.

  • Save SuspendingServices.kt.

Seeing an unresolved reference on delay?

Make sure the import kotlinx.coroutines.delay line is at the top of the file. If IntelliJ shows the import in red, your build.gradle.kts may not have synced the coroutines dependency. Click the Gradle sync icon in the toolbar to refresh.

Help me fix an unresolved reference to delay in my Kotlin coroutines project.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

package services

import kotlinx.coroutines.delay

object SuspendingServices {

    suspend fun fetchUserDetails(userId: String): String {
        delay(200)
        return "User(id=$userId, name=Alice, email=alice@example.com)"
    }

    suspend fun fetchOrders(userId: String): String {
        delay(300)
        return "Orders(userId=$userId, count=5, lastOrder=2026-06-15)"
    }

    suspend fun fetchPreferences(userId: String): String {
        delay(150)
        return "Preferences(userId=$userId, theme=dark, notifications=true)"
    }
}

Now you have coroutine-friendly versions of the same services. Next, you will write the fetcher that launches all three concurrently using async.

  • Create a new Kotlin file called CoroutineFetcher.kt inside src/main/kotlin/ (at the top level, not inside the services package).
  • Right-click the kotlin folder in the Project tool window, select New > Kotlin Class/File, choose File, name it CoroutineFetcher, and press Enter.
  • Add the fetchWithCoroutines() function by pasting this code:
import kotlinx.coroutines.*
import services.SuspendingServices

suspend fun fetchWithCoroutines(userId: String): List<String> = coroutineScope {
    val userDetails = async {
        val result = SuspendingServices.fetchUserDetails(userId)
        println("  [Coroutine] fetchUserDetails on thread: ${Thread.currentThread().name}")
        result
    }

    val orders = async {
        val result = SuspendingServices.fetchOrders(userId)
        println("  [Coroutine] fetchOrders on thread: ${Thread.currentThread().name}")
        result
    }

    val preferences = async {
        val result = SuspendingServices.fetchPreferences(userId)
        println("  [Coroutine] fetchPreferences on thread: ${Thread.currentThread().name}")
        result
    }

    listOf(userDetails.await(), orders.await(), preferences.await())
}

What does this code do?

The coroutineScope block creates a structured boundary. All async coroutines launched inside it are children of this scope.

Each async call starts a concurrent computation and returns a Deferred handle. Calling .await() suspends until the result is ready. All three service calls run concurrently on a shared thread pool.

  • Save CoroutineFetcher.kt.
  • You will not see a change yet. The next substep adds error handling, and then you will run everything together.

Red underlines on coroutineScope or async?

Verify that the first line reads import kotlinx.coroutines.*. The wildcard import pulls in coroutineScope, async, and delay together.

Help me resolve red underlines on coroutineScope and async in my Kotlin file.

Add error handling to demonstrate structured concurrency

Structured concurrency means the parent scope owns its children. If one child fails, the parent cancels all siblings automatically. You do not need to track or cancel them yourself.

  • In CoroutineFetcher.kt, add the following function below fetchWithCoroutines():
suspend fun fetchWithCoroutinesErrorDemo(userId: String): List<String> = try {
    coroutineScope {
        val userDetails = async {
            SuspendingServices.fetchUserDetails(userId)
        }

        val orders = async {
            delay(50)
            throw RuntimeException("Orders service is down!")
        }

        val preferences = async {
            SuspendingServices.fetchPreferences(userId)
        }

        listOf(userDetails.await(), orders.await(), preferences.await())
    }
} catch (e: RuntimeException) {
    println("  [Coroutine] Caught: ${e.message}")
    println("  [Coroutine] All sibling coroutines were automatically cancelled!")
    listOf("Error: ${e.message}")
}

What does this code do?

The orders coroutine waits 50ms then throws a RuntimeException. Because coroutineScope enforces structured concurrency, the userDetails and preferences coroutines are automatically cancelled when the exception propagates.

The outer try/catch catches the exception and returns a fallback value. With threads, you would need to manually call Future.cancel() or Thread.interrupt() on each sibling.

  • Save CoroutineFetcher.kt.

Compiler error on the try expression?

Make sure the = try { is on the same line as the function signature. Kotlin allows try as an expression (it returns a value), so the function body is the entire try/catch block.

Help me fix a compiler error on my fetchWithCoroutinesErrorDemo function.

✔️ Awesome, I've got everything!

Great. Make sure both functions are saved in CoroutineFetcher.kt before continuing.

ⓧ I'd like to double check the full code

import kotlinx.coroutines.*
import services.SuspendingServices

suspend fun fetchWithCoroutines(userId: String): List<String> = coroutineScope {
    val userDetails = async {
        val result = SuspendingServices.fetchUserDetails(userId)
        println("  [Coroutine] fetchUserDetails on thread: ${Thread.currentThread().name}")
        result
    }

    val orders = async {
        val result = SuspendingServices.fetchOrders(userId)
        println("  [Coroutine] fetchOrders on thread: ${Thread.currentThread().name}")
        result
    }

    val preferences = async {
        val result = SuspendingServices.fetchPreferences(userId)
        println("  [Coroutine] fetchPreferences on thread: ${Thread.currentThread().name}")
        result
    }

    listOf(userDetails.await(), orders.await(), preferences.await())
}

suspend fun fetchWithCoroutinesErrorDemo(userId: String): List<String> = try {
    coroutineScope {
        val userDetails = async {
            SuspendingServices.fetchUserDetails(userId)
        }

        val orders = async {
            delay(50)
            throw RuntimeException("Orders service is down!")
        }

        val preferences = async {
            SuspendingServices.fetchPreferences(userId)
        }

        listOf(userDetails.await(), orders.await(), preferences.await())
    }
} catch (e: RuntimeException) {
    println("  [Coroutine] Caught: ${e.message}")
    println("  [Coroutine] All sibling coroutines were automatically cancelled!")
    listOf("Error: ${e.message}")
}

Why coroutines instead of just virtual threads?

Virtual threads solve the resource cost problem (lighter than platform threads), but they do not give you structured concurrency. If one virtual thread fails, you must manually cancel the others using Future.cancel().

Coroutines give you both: lightweight execution (approximately 256 bytes per suspended coroutine) AND automatic lifecycle management. A parent cancels children, errors propagate cleanly, and you never leak background work.

Create the main runner with timing comparison

The final piece is a main() function that runs all three approaches back-to-back, measures their execution time, and prints a comparison summary.

  • Create a new Kotlin file called Main.kt inside src/main/kotlin/ (at the top level).
  • Right-click the kotlin folder, select New > Kotlin Class/File, choose File, name it Main, and press Enter.
  • Paste the following code into Main.kt:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext

fun main() = runBlocking {
    val userId = "user-42"

    println("=== Concurrent Data Fetcher: Three Approaches ===")
    println()

    // 1. Platform Threads
    println("--- Approach 1: Platform Threads ---")
    val platformStart = System.currentTimeMillis()
    val platformResults = fetchWithPlatformThreads(userId)
    val platformTime = System.currentTimeMillis() - platformStart
    println("  Results: $platformResults")
    println("  Time: ${platformTime}ms")
    println()

    // 2. Virtual Threads
    println("--- Approach 2: Virtual Threads (Java 21) ---")
    val virtualStart = System.currentTimeMillis()
    val virtualResults = fetchWithVirtualThreads(userId)
    val virtualTime = System.currentTimeMillis() - virtualStart
    println("  Results: $virtualResults")
    println("  Time: ${virtualTime}ms")
    println()

    // 3. Kotlin Coroutines
    println("--- Approach 3: Kotlin Coroutines ---")
    val coroutineStart = System.currentTimeMillis()
    val coroutineResults = withContext(Dispatchers.Default) {
        fetchWithCoroutines(userId)
    }
    val coroutineTime = System.currentTimeMillis() - coroutineStart
    println("  Results: $coroutineResults")
    println("  Time: ${coroutineTime}ms")
    println()

    // 4. Structured Concurrency Error Demo
    println("--- Bonus: Structured Concurrency Error Handling ---")
    withContext(Dispatchers.Default) {
        fetchWithCoroutinesErrorDemo(userId)
    }
    println()

    // Summary
    println("=== Summary ===")
    println("  Platform Threads: ${platformTime}ms (3 OS threads created)")
    println("  Virtual Threads:  ${virtualTime}ms (3 virtual threads, lighter)")
    println("  Coroutines:       ${coroutineTime}ms (shared thread pool, structured concurrency)")
    println()
    println("Key Takeaway:")
    println("  All three complete in ~300ms (the slowest service call).")
    println("  Platform threads cost ~1MB each. Virtual threads cost ~1-2KB each.")
    println("  Coroutines cost ~256 bytes each AND give you structured concurrency.")
}

What does this code do?

The runBlocking builder bridges the non-coroutine world (the JVM main entry point) with the coroutine world. It blocks the main thread until all coroutines inside complete.

The withContext(Dispatchers.Default) switches coroutine execution to a shared background thread pool. This lets you see that coroutines reuse threads instead of creating new ones for each task.

Each approach is timed with System.currentTimeMillis() so you can compare their performance side by side.

  • Save Main.kt.
  • Run the program by clicking the green Run icon in the gutter next to fun main() and selecting Run 'MainKt'.
  • The Run tool window opens at the bottom of IntelliJ. You should see output from all three approaches completing in approximately 300ms each, plus the error demo printing that sibling coroutines were automatically cancelled.

No green Run icon next to main?

IntelliJ needs a moment to index new files. Wait a few seconds for indexing to finish. If the icon still does not appear, check that your file is named Main.kt and contains a top-level fun main() (not inside a class).

Help me troubleshoot why IntelliJ is not showing the Run button for my Kotlin main function.

✔️ Awesome, I've got everything!

Great. Make sure your output shows all three approaches completing in similar time and the error demo printing the cancellation message.

ⓧ I'd like to double check the full code

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext

fun main() = runBlocking {
    val userId = "user-42"

    println("=== Concurrent Data Fetcher: Three Approaches ===")
    println()

    // 1. Platform Threads
    println("--- Approach 1: Platform Threads ---")
    val platformStart = System.currentTimeMillis()
    val platformResults = fetchWithPlatformThreads(userId)
    val platformTime = System.currentTimeMillis() - platformStart
    println("  Results: $platformResults")
    println("  Time: ${platformTime}ms")
    println()

    // 2. Virtual Threads
    println("--- Approach 2: Virtual Threads (Java 21) ---")
    val virtualStart = System.currentTimeMillis()
    val virtualResults = fetchWithVirtualThreads(userId)
    val virtualTime = System.currentTimeMillis() - virtualStart
    println("  Results: $virtualResults")
    println("  Time: ${virtualTime}ms")
    println()

    // 3. Kotlin Coroutines
    println("--- Approach 3: Kotlin Coroutines ---")
    val coroutineStart = System.currentTimeMillis()
    val coroutineResults = withContext(Dispatchers.Default) {
        fetchWithCoroutines(userId)
    }
    val coroutineTime = System.currentTimeMillis() - coroutineStart
    println("  Results: $coroutineResults")
    println("  Time: ${coroutineTime}ms")
    println()

    // 4. Structured Concurrency Error Demo
    println("--- Bonus: Structured Concurrency Error Handling ---")
    withContext(Dispatchers.Default) {
        fetchWithCoroutinesErrorDemo(userId)
    }
    println()

    // Summary
    println("=== Summary ===")
    println("  Platform Threads: ${platformTime}ms (3 OS threads created)")
    println("  Virtual Threads:  ${virtualTime}ms (3 virtual threads, lighter)")
    println("  Coroutines:       ${coroutineTime}ms (shared thread pool, structured concurrency)")
    println()
    println("Key Takeaway:")
    println("  All three complete in ~300ms (the slowest service call).")
    println("  Platform threads cost ~1MB each. Virtual threads cost ~1-2KB each.")
    println("  Coroutines cost ~256 bytes each AND give you structured concurrency.")
}

Secret mission

Add Timeout and Cancellation Demo

Your coroutines version already handles errors cleanly. But what about partial failure? In production, you often want some services to succeed even if one times out. Add timeout handling with supervisorScope to demonstrate graceful degradation.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files on disk or remove them entirely.

Resources you used:

  • Kotlin/JVM Gradle project folder (contains build.gradle.kts, src/ directory, and all source files).
  • JDK 21 downloaded via IntelliJ IDEA's built-in JDK manager.

Keep everything running

No action needed. Choose this if you plan to revisit the project, extend the concurrency examples, or use this codebase as a reference for future Kotlin work.

  • Your project folder stays on disk wherever you created it.
  • JDK 21 remains available in IntelliJ IDEA for any other project that targets Java 21.
  • The Gradle wrapper cache inside your project stays intact for faster builds next time.

Pause - I'll come back to this later

Close IntelliJ IDEA to free up memory. Your project files remain on disk for when you return.

  • In IntelliJ IDEA, click File, then click Close Project.
  • Quit IntelliJ IDEA completely to reclaim memory.
  • When you return, open IntelliJ IDEA and select your project from the Recent Projects list on the Welcome screen.

Delete - I don't want to use this again

Remove all project files and optionally the downloaded JDK to start completely fresh.

  • Find your project folder path by checking the IntelliJ IDEA title bar (it shows the full path to your project).
  • Close the project in IntelliJ IDEA by clicking File, then Close Project.
  • Delete the project folder from your file system by opening a terminal, navigating to the parent directory, and running the appropriate command below.

On macOS/Linux:

rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]

On Windows:

rmdir /s /q [[PROJECT_FOLDER="your-project-folder-name"]]
  • To remove JDK 21 from IntelliJ IDEA, open any project (or create a blank one), then click File, then Project Structure.
  • Select Platform Settings, then SDKs.
  • Select the JDK 21 entry. Note the JDK home path shown at the top of the panel. Click the minus icon to remove it from IntelliJ.
  • Delete the JDK folder from disk using the path you noted above.
rm -rf [[JDK_PATH="path shown in IntelliJ SDKs panel"]]

Not sure which folder to delete?

The exact folder name depends on the JDK vendor you selected when downloading (e.g. Amazon Corretto, Eclipse Temurin, Oracle OpenJDK). The path displayed in the SDKs panel is the authoritative location. Copy it exactly when running the delete command.

That's a Wrap!

That's a Wrap!

Nice work. You just built a concurrent data fetcher three different ways and experienced the full evolution of JVM concurrency firsthand.

You've learned how to:

  • Build concurrent code with platform threads, virtual threads, and Kotlin coroutines, comparing resource costs and execution models across all three approaches.
  • Use structured concurrency with coroutineScope and async to get automatic cancellation and clean error propagation when a sibling coroutine fails.
  • Articulate the key mental model: platform threads cost ~1MB each, virtual threads cost ~1-2KB each, and coroutines cost ~256 bytes each while also providing structured lifecycle management.
  • Secret Mission: Implemented timeout handling with withTimeout and supervisorScope to demonstrate graceful partial failure without cancelling sibling coroutines.

Ready to quiz yourself?