Skip to Content
Course content

202: Common Kotlin Interview Questions on Coroutines vs Threads

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've sat through dozens of technical interviews, and there is one answer that makes me immediately lean in—not because it's right, but because it's so common. When I ask, "What is the difference between a thread and a coroutine?" the candidate almost always says: "Coroutines are just lightweight threads."

On the surface, that sounds correct. But it's a dangerous simplification. If you tell an interviewer that, you're essentially saying "a car is just a fast bicycle." It misses the fundamental architectural difference: how they handle waiting.

The "Lightweight Thread" Myth vs. The Reality of Memory

If coroutines were just "small threads," we'd still be limited by the OS. Let's look at why that's wrong. Every JVM thread carries a hefty memory overhead—usually around 1MB for the stack. If you try to spawn 100,000 threads to handle 100,000 concurrent API requests, your application will crash with an OutOfMemoryError long before you hit that number.

// This will likely crash your JVM
repeat(100_000) {
    Thread { 
        Thread.sleep(1000) 
        println("Done") 
    }.start()
}

Now, try that with coroutines using Dispatchers.Default. It won't crash. Why? Because you aren't actually creating 100,000 threads. You're creating 100,000 objects (continuations) that represent a piece of work. These objects are stored on the heap, which is vastly cheaper than allocating a native OS stack for every single task. I usually tell my juniors to think of it this way: a thread is like a dedicated physical lane on a highway, while a coroutine is more like a car that can pull over into a parking spot when it needs to wait for a green light, letting other cars pass by.

Blocking the Thread vs. Suspending the Coroutine

This is the "million-dollar" interview point. When a thread calls Thread.sleep() or waits for a database response, that thread is blocked. It sits there, doing absolutely nothing, but it still consumes that 1MB of memory and prevents the OS from using it for anything else.

Coroutines use suspension. When a coroutine hits a suspend function (like delay() or a Ktor network call), it doesn't block the thread it's running on. Instead, it "unhooks" itself from the thread, saves its local state, and lets the thread go back to the pool to do other work. When the timer expires or the data returns, the coroutine is "resumed" on any available thread from the dispatcher.

// The "Blocking" Way (Thread-per-request)
fun fetchUserBlocking() {
    val user = api.getUser() // Thread stops here and waits. Wasteful.
    println(user)
}

// The "Suspending" Way (Coroutine)
suspend fun fetchUserSuspending() {
    val user = api.getUser() // Coroutine pauses; thread is freed for others.
    println(user) // Resumes here once data is ready.
}

If you're asked about "Context Switching" in an interview, mention this. Switching between OS threads requires a transition into the kernel, which is expensive. Switching between coroutines happens in the Kotlin runtime (user-space), which is orders of magnitude faster.

The "One-to-Many" Mapping

To wrap this up, you need to be clear on the mapping. Threads are managed by the Operating System. Coroutines are managed by the Kotlin library. A single thread can execute thousands of coroutines over its lifetime by jumping between them as they suspend and resume.

Don't get confused: coroutines don't magically make your code run in parallel on their own. If you launch 1,000 coroutines on Dispatchers.Main, they are all running on one single thread. They just aren't blocking it. Parallelism requires a multi-threaded dispatcher like Dispatchers.Default or Dispatchers.IO, but the efficiency comes from the fact that those threads are never sitting idle while waiting for I/O.




📋 Practical Task

Exercise: Converting a Blocking Thread-Pool to a Suspending Coroutine System

You have been handed a legacy "User Notification System" that uses a FixedThreadPool. The current implementation is crashing under high load because it creates too many threads to handle the simulated network latency of sending emails.

Your Task:

  1. Remove the ExecutorService and Thread.sleep().
  2. Implement the sendNotification logic using suspend functions and delay().
  3. Use coroutineScope and launch to trigger 1,000 notifications concurrently without crashing the JVM or blocking the main thread.

// LEGACY CODE TO FIX
class NotificationService {
    private val executor = Executors.newFixedThreadPool(10)

    fun sendNotification(userId: String) {
        executor.execute {
            println("Sending to $userId on ${Thread.currentThread().name}")
            Thread.sleep(500) // Simulating network delay
            println("Sent to $userId")
        }
    }
}

fun main() {
    val service = NotificationService()
    repeat(1000) { i ->
        service.sendNotification("User-$i")
    }
}

Success Criteria: Your final code should be able to handle 1,000 (or even 10,000) calls to sendNotification almost instantaneously without creating 1,000 OS threads, and it should utilize Dispatchers.IO for the simulated network work.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.