Waiting without blocking
Imagine a chef who puts bread in the oven and then stands there staring at it for ten minutes. That’s a blocked thread: it’s busy doing nothing. A good chef starts the bread, chops vegetables meanwhile, and comes back when the timer rings.
Programs wait constantly: for the network, the disk, the database. All four languages let your code “start something and come back later” without wasting a thread:
- Swift: functions marked
async; youawaittheir results. While waiting, the thread does other work. - Kotlin:
suspendfunctions run in coroutines; they suspend while waiting and the thread is freed. - Java: virtual threads (Java 21+) look like normal threads and can call normal blocking code, but they’re so cheap that blocking doesn’t matter: the JVM parks the virtual thread and reuses the real one.
- TypeScript: JavaScript has just one thread for your code. An
asyncfunction returns a promise; at eachawait, the function steps aside, and the same thread runs other code (other requests, other taps) until the result arrives.
All four decouple logical tasks from OS threads, but in different ways. JavaScript is the extreme case: one thread runs every callback from an event loop, promises carry results, and await is syntax over them; blocking that one thread blocks everything. Swift and Kotlin use compiler-transformed functions (async/suspend become state machines) scheduled on small thread pools, so suspension points are explicit in the code; Swift adds compile-time data-race safety with actors and Sendable. Java’s virtual threads are user-mode threads in the runtime: existing blocking APIs unmount the virtual thread from its carrier, so synchronous code scales without being rewritten, and suspension points are invisible.
The trade-offs follow: explicit vs implicit suspension, compile-time vs runtime safety, structured concurrency built in (Swift, Kotlin) vs still in preview (Java’s StructuredTaskScope), and the ways each model can be blocked or starved. The Detailed version compares them.
func loadDashboard() async throws -> Dashboard {
async let profile = api.profile() // starts now
async let orders = api.orders() // starts now, in parallel
return try await Dashboard(profile: profile, orders: orders) // waits for both
}suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { api.profile() } // starts now
val orders = async { api.orders() } // starts now, in parallel
Dashboard(profile.await(), orders.await()) // waits for both
}Dashboard loadDashboard() throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Profile> profile = executor.submit(api::profile); // a blocking call on a cheap thread
Future<List<Order>> orders = executor.submit(api::orders); // in parallel
return new Dashboard(profile.get(), orders.get()); // waits for both
} // close() waits for every task
}async function loadDashboard(): Promise<Dashboard> {
const [profile, orders] = await Promise.all([api.profile(), api.orders()]); // both start now
return { profile, orders }; // after both finished
}The shape is the same in all four: start both pieces of work, then wait for both. The total time is the slower of the two, not the sum.
The details differ:
- Swift, Kotlin and TypeScript mark every place that may wait (
await,suspend), so you can see where a function pauses. In TypeScript, callingapi.profile()starts the request right away;Promise.allwaits for both. - Java code looks like ordinary blocking code (
profile.get()blocks), but it runs on virtual threads, which cost almost nothing to create and to block. Thetry (…)block makes sure both tasks finish before the method returns.
How cheap is cheap?
Predict the output
10,000 tasks each wait one second. About how many seconds does the whole thing take?
let elapsed = await ContinuousClock().measure {
await withTaskGroup(of: Void.self) { group in
for _ in 0..<10_000 {
group.addTask { try? await Task.sleep(for: .seconds(1)) }
}
}
}
print(elapsed.components.seconds)val elapsed = measureTimeMillis {
runBlocking {
repeat(10_000) { launch { delay(1_000) } }
}
}
println(elapsed / 1_000)long start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> { Thread.sleep(1_000); return null; });
}
}
System.out.println((System.nanoTime() - start) / 1_000_000_000);const start = Date.now();
await Promise.all(
Array.from({ length: 10_000 }, () => new Promise((resolve) => setTimeout(resolve, 1_000))),
);
console.log(Math.round((Date.now() - start) / 1_000));All 10,000 waits overlap, and none of them occupies a real thread while waiting: Swift tasks and Kotlin coroutines suspend, Java's virtual threads unmount from their carrier thread when they sleep, and JavaScript timers just schedule a callback on the event loop. The total is about one second, on a handful of OS threads. With 10,000 classic platform threads, each reserves its own stack (often 512 KB to 1 MB), and with a fixed pool of 10 threads, the same work would take 1,000 seconds.
The vocabulary, side by side
| Idea | Swift | Kotlin | Java | TypeScript |
|---|---|---|---|---|
| A function that may wait | async func | suspend fun | any method (on a virtual thread) | async function (returns a Promise) |
| Wait for a result | await | call it (inside a coroutine) | call it (it blocks the virtual thread) | await |
| Start parallel work | async let, TaskGroup, Task {} | async {}, launch {} | executor.submit(…), Thread.startVirtualThread | call the async function (it starts immediately) |
| Wait for parallel work | await, group.next() | await(), join(), scope end | future.get(), executor close() | Promise.all, Promise.allSettled |
| Protect shared state | actor, Mutex | Mutex, atomics, single thread | ReentrantLock, atomics, concurrent collections | one thread; avoid read-await-write |
| Cancel | task.cancel() | job.cancel() | future.cancel(true) (interrupts) | AbortController.abort() |
Models compared
| Swift | Kotlin | Java virtual threads | TypeScript (JavaScript) | |
|---|---|---|---|---|
| Mechanism | compiler state machines, cooperative pool (≈ CPU cores) | compiler state machines, dispatchers (Default ≈ cores, IO 64+) | runtime-managed threads mounted on carrier threads (≈ cores) | one event-loop thread; promises; worker threads for CPU work |
| Suspension points | explicit (await) | explicit (suspend calls) | implicit (any blocking JDK call) | explicit (await) |
| Existing blocking code | must not run on the pool | must move to Dispatchers.IO | just works (mostly) | blocks everything: use async APIs or a worker |
| Structured concurrency | built in (async let, task groups) | built in (scopes) | StructuredTaskScope (preview), else manual | none: pass an AbortSignal by hand |
| Data-race safety | compile time (Swift 6: actors, Sendable) | none (discipline, Mutex, atomics) | none (locks, atomics, immutability) | no shared memory, but interleaving at every await |
| Cancellation | cooperative flag + throwing APIs | cooperative CancellationException | interruption | AbortSignal, checked by the callee |
Java virtual threads in practice
- Don’t pool them: create one per task (
newVirtualThreadPerTaskExecutor). Pooling limits concurrency for no benefit. - Limit downstream concurrency explicitly: virtual threads make it trivial to start 10,000 database calls; a
Semaphore(or the connection pool itself) decides how many actually run (puzzle below). - Pinning: until JDK 24 (JEP 491), blocking inside
synchronizedpinned the virtual thread to its carrier, so a few such blocks could stall every carrier. Native calls still pin. PreferReentrantLockaround blocking code, and watchjdk.VirtualThreadPinnedevents in JFR. - ThreadLocals work but are per virtual thread: caching heavy objects in them (formatters, buffers) multiplies memory by the number of tasks.
ScopedValueis the replacement for passing context.
10,000 virtual threads, 10 connections
Predict the output
A service handles 10,000 concurrent requests on virtual threads (or coroutines, Swift tasks, Node promises). Each runs one database query. The connection pool has 10 connections. How many queries run at the same time?
Lightweight concurrency removes the thread limit, not every other limit. The connection pool is now the real bottleneck: 10 queries run, 9,990 requests wait in the pool's queue, and with a 30-second acquisition timeout, many fail. Before virtual threads, a thread pool of 200 accidentally limited the pressure on the database; now you must limit it deliberately: size the pool for the database, bound waiting time, put a Semaphore/bulkhead around the dependency, and shed load early.
| Swift | Kotlin | Java (21+) | TypeScript | |
|---|---|---|---|---|
| Parallel, fixed count | async let | async {} in coroutineScope | submit on newVirtualThreadPerTaskExecutor() | Promise.all([a(), b()]) |
| Suspension points | explicit (await) | explicit (suspend calls) | implicit (any blocking JDK call) | explicit (await) |
| Threads | cooperative pool ≈ cores | dispatchers (Default ≈ cores, IO 64+) | virtual threads on carriers ≈ cores | one event-loop thread (+ workers) |
Lightweight concurrency removes the thread limit, not the others: with 10,000 concurrent requests and a pool of 10 database connections, 10 queries run and the rest queue (and time out). Limit downstream concurrency deliberately.
Java
Don’t pool virtual threads; create one per task. ThreadLocals are per virtual thread (use ScopedValue for context). Before JDK 24, blocking inside synchronized pinned the carrier.
TypeScript
Promises start running when they’re created; await only waits. All your JavaScript runs on one thread, so CPU-heavy work goes to worker threads.
Check this part
Two requests take one second each. You start both, then wait for both. About how long does it take?
While your code waits for the network, what happens to the thread?
Where can an
asyncfunction pause?Where can asuspendfunction pause?Where can code on a virtual thread pause?10,000 concurrent requests each run one database query through a pool of 10 connections. How many queries run at once?