Skip to content
Commitwise
Menu
Programming

Concurrency: async/await, coroutines and virtual threads

Run work concurrently without blocking, with Swift async/await, Kotlin coroutines, Java virtual threads and TypeScript promises, compared side by side, from parallel requests and timeouts to shared state and the bugs each model makes easy.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A screen needs a profile and a list of orders, each taking a second to load. Loaded one after the other: two seconds. Loaded at the same time: one. A server handles 10,000 requests that each wait on a database. With one operating-system thread per request, that’s 10,000 threads and gigabytes of memory; with lightweight concurrency, it’s a few threads and almost nothing.

Swift, Kotlin, Java and TypeScript arrived at the same goal from different directions: Swift’s async/await with actors, Kotlin’s coroutines, Java’s virtual threadsVirtual threadA lightweight Java thread managed by the JVM, cheap enough to create one per task and to block.Open in glossary →, and JavaScript’s promises on a single-threaded event loop. This tutorial puts them side by side.

New to some of these? Open what you need, or skip ahead.

Basics:What are threads and the main thread?

A thread is a line of work the computer runs; a program can run several at the same time. Apps have one special thread, the main thread (also called the UI thread), which draws the screen and handles taps.

If code on the main thread waits for something slow, like the network, the screen freezes: no scrolling, no animations. On Android, a few seconds of that shows the “App isn’t responding” dialog. That’s why slow work must happen without blocking the main thread.

Basics:What does asynchronous mean?

Synchronous code does one thing at a time and waits for each step to finish. Asynchronous code starts something slow (a download), lets other work continue in the meantime, and continues when the result is ready.

Swift (async/await), Kotlin (suspend functions in coroutines), Java (virtual threads, futures) and TypeScript (async/await on promises) each have their own way to write asynchronous code that still reads top to bottom, like normal code. The waiting doesn’t freeze the app.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)
TS:      numbers.filter((n) => n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What are databases, tables and SQL?

A database stores an app’s data so it survives restarts and can be searched quickly. A relational database (PostgreSQL, MySQL) keeps data in tables: rows (one per book, user or order) and columns (id, title, year).

You talk to it with SQL: SELECT title FROM books WHERE year > 2000 reads, INSERT, UPDATE and DELETE change data. Rows refer to other tables through foreign keys (book.author_id points to a row in author), and indexes make lookups by a column fast.

Part Not started

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; you await their results. While waiting, the thread does other work.
  • Kotlin: suspend functions 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 async function returns a promise; at each await, 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, calling api.profile() starts the request right away; Promise.all waits 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. The try (…) 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));

The vocabulary, side by side

IdeaSwiftKotlinJavaTypeScript
A function that may waitasync funcsuspend funany method (on a virtual thread)async function (returns a Promise)
Wait for a resultawaitcall it (inside a coroutine)call it (it blocks the virtual thread)await
Start parallel workasync let, TaskGroup, Task {}async {}, launch {}executor.submit(…), Thread.startVirtualThreadcall the async function (it starts immediately)
Wait for parallel workawait, group.next()await(), join(), scope endfuture.get(), executor close()Promise.all, Promise.allSettled
Protect shared stateactor, MutexMutex, atomics, single threadReentrantLock, atomics, concurrent collectionsone thread; avoid read-await-write
Canceltask.cancel()job.cancel()future.cancel(true) (interrupts)AbortController.abort()

Models compared

SwiftKotlinJava virtual threadsTypeScript (JavaScript)
Mechanismcompiler 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 pointsexplicit (await)explicit (suspend calls)implicit (any blocking JDK call)explicit (await)
Existing blocking codemust not run on the poolmust move to Dispatchers.IOjust works (mostly)blocks everything: use async APIs or a worker
Structured concurrencybuilt in (async let, task groups)built in (scopes)StructuredTaskScope (preview), else manualnone: pass an AbortSignal by hand
Data-race safetycompile time (Swift 6: actors, Sendable)none (discipline, Mutex, atomics)none (locks, atomics, immutability)no shared memory, but interleaving at every await
Cancellationcooperative flag + throwing APIscooperative CancellationExceptioninterruptionAbortSignal, 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 synchronized pinned the virtual thread to its carrier, so a few such blocks could stall every carrier. Native calls still pin. Prefer ReentrantLock around blocking code, and watch jdk.VirtualThreadPinned events in JFR.
  • ThreadLocals work but are per virtual thread: caching heavy objects in them (formatters, buffers) multiplies memory by the number of tasks. ScopedValue is 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?

SwiftKotlinJava (21+)TypeScript
Parallel, fixed countasync letasync {} in coroutineScopesubmit on newVirtualThreadPerTaskExecutor()Promise.all([a(), b()])
Suspension pointsexplicit (await)explicit (suspend calls)implicit (any blocking JDK call)explicit (await)
Threadscooperative pool ≈ coresdispatchers (Default ≈ cores, IO 64+)virtual threads on carriers ≈ coresone 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

  1. Two requests take one second each. You start both, then wait for both. About how long does it take?

  2. While your code waits for the network, what happens to the thread?

  3. Where can an async function pause?Where can a suspend function pause?Where can code on a virtual thread pause?

  4. 10,000 concurrent requests each run one database query through a pool of 10 connections. How many queries run at once?

Waiting shouldn't block a thread: start independent work together, then wait for all of it.

Part Not started

One at a time, by accident

Starting work together is easy to forget. This code passes review and still downloads every image in turn:

Spot the bugA gallery that loads one image at a time

Tap the line with the bug.

func loadGallery(_ urls: [URL]) async throws -> [Data] {
    var images: [Data] = []
    for url in urls {
        images.append(try await download(url))
    }
    return images
}
suspend fun loadGallery(urls: List<String>): List<ByteArray> {
    val images = mutableListOf<ByteArray>()
    for (url in urls) {
        images += download(url)
    }
    return images
}
List<byte[]> loadGallery(List<URI> uris) throws Exception {
    List<byte[]> images = new ArrayList<>();
    for (URI uri : uris) {
        images.add(download(uri));
    }
    return images;
}
async function loadGallery(urls: string[]): Promise<Blob[]> {
  const images: Blob[] = [];
  for (const url of urls) {
    images.push(await download(url));
  }
  return images;
}

Practice: timeouts and cancellation

A request that never answers must not hang the app. Each language can put a deadline on work, step by step:

  1. Start the work as a cancellable unit (a task, a coroutine, a future).
  2. Race it against a timer: whichever finishes first wins.
  3. Cancel the loser, so a slow request doesn’t keep running (and using the network) in the background.
  4. Report the timeout as its own error, so the UI can say “taking too long, try again”.
struct TimeoutError: Error {}

func withTimeout<T: Sendable>(_ limit: Duration, _ work: @escaping @Sendable () async throws -> T) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await work() }
        group.addTask { try await Task.sleep(for: limit); throw TimeoutError() }
        let first = try await group.next()!          // whichever finishes (or fails) first
        group.cancelAll()                            // cancel the other one
        return first
    }
}

let prices = try await withTimeout(.seconds(2)) { try await api.prices() }
val prices = withTimeout(2.seconds) {                // cancels the block and throws on timeout
    api.prices()
}

// or withTimeoutOrNull(2.seconds) { … } to get null instead of an exception
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<Prices> future = executor.submit(api::prices);
    try {
        Prices prices = future.get(2, TimeUnit.SECONDS);
    } catch (TimeoutException e) {
        future.cancel(true);                         // interrupts the virtual thread
        throw new PricesTimeoutException(e);
    }
}
const prices = await api.prices({ signal: AbortSignal.timeout(2_000) });   // aborts the request after 2 s

// AbortSignal.timeout rejects with a DOMException named "TimeoutError"

Cancellation only helps if the work responds to it. Network and sleep calls do in all three languages. A long loop of pure computation must check: try Task.checkCancellation() in Swift, ensureActive() in Kotlin, Thread.interrupted() in Java, signal.throwIfAborted() in TypeScript.

Exercisemedium

All or nothing

An export needs five files from a server. If any download fails, the export can’t be built: the other downloads should stop immediately (they waste bandwidth and battery), and the error should be reported. If all succeed, return them in the original order.

Write downloadAll(urls) in your language so that a failure cancels the remaining downloads.

›

Structured concurrency does most of this for you: in a Swift throwing task group and a Kotlin coroutineScope, a failing child cancels its siblings and the error is rethrown.

›

Java’s executor doesn’t cancel siblings on its own. Catch the failure, cancel every future (cancel(true) interrupts virtual threads), then rethrow. TypeScript’s Promise.all rejects on the first failure but lets the others run: share an AbortController and abort() it.

SolutionTry it yourself firstTime O(n)Space O(n)
func downloadAll(_ urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: (Int, Data).self) { group in
        for (i, url) in urls.enumerated() {
            group.addTask { let data = try await download(url); return (i, data) }
        }
        var files = [Data?](repeating: nil, count: urls.count)
        for try await (i, data) in group {      // the first error is thrown here,
            files[i] = data                     // and leaving the group cancels the remaining tasks
        }
        return files.map { $0! }
    }
}
suspend fun downloadAll(urls: List<String>): List<ByteArray> = coroutineScope {
    urls.map { url -> async { download(url) } }  // a failing async cancels the scope and its siblings
        .awaitAll()                              // rethrows the first failure
}
List<byte[]> downloadAll(List<URI> uris) throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var completion = new ExecutorCompletionService<Map.Entry<Integer, byte[]>>(executor);
        List<Future<Map.Entry<Integer, byte[]>>> futures = new ArrayList<>();
        for (int i = 0; i < uris.size(); i++) {
            int index = i;
            futures.add(completion.submit(() -> Map.entry(index, download(uris.get(index)))));
        }
        byte[][] files = new byte[uris.size()][];
        try {
            for (int done = 0; done < uris.size(); done++) {
                var result = completion.take().get();     // in completion order: fails fast
                files[result.getKey()] = result.getValue();
            }
        } catch (ExecutionException e) {
            futures.forEach(f -> f.cancel(true));         // stop the others
            throw e;
        }
        return Arrays.asList(files);
    }
}
async function downloadAll(urls: string[]): Promise<Blob[]> {
  const controller = new AbortController();
  try {
    return await Promise.all(urls.map((url) => download(url, controller.signal)));   // in order
  } catch (e) {
    controller.abort();             // Promise.all rejected, but the other downloads are still running
    throw e;
  }
}

In Swift and Kotlin, “if one fails, cancel the rest” is the default behavior of structured concurrency: the code is just “start all, await all”. TypeScript needs the AbortController, because a promise can’t be cancelled from outside; the function you call must accept a signal. Java needs it written by hand: an ExecutorCompletionService sees failures in the order they happen (not the order of the list), and every future is cancelled explicitly. Java’s StructuredTaskScope (a preview API at the time of writing) is designed to make this as short as the other two.

Exercisehard

A fan-out aggregator

A “home screen” API calls five downstream services per request (profile, recommendations, cart, promotions, notifications) and must answer within 800 ms. Recommendations are slow and sometimes fail; the page can render without recommendations, promotions and notifications, but not without profile and cart. Under a traffic spike, one slow downstream must not consume all the server’s resources.

Design the concurrency: how calls are started and awaited, deadlines, partial results, cancellation and isolation between downstreams. Sketch it in Kotlin or Java (with virtual threads).

›

Separate required from optional calls: required failures fail the request; optional ones become empty sections. Every call gets a deadline derived from the overall budget, not its own fixed timeout.

›

Isolation means a limit per downstream (a bulkhead): a semaphore with a few dozen permits per service, failing fast when exhausted, so a slow recommendations service can’t hold every request hostage.

SolutionTry it yourself first
class HomeAggregator(
    private val profile: ProfileClient,
    private val cart: CartClient,
    private val recs: RecommendationsClient,
    private val promos: PromotionsClient,
    private val notifications: NotificationsClient,
) {
    private val recsBulkhead = Semaphore(permits = 50)            // per-downstream isolation
    private val promosBulkhead = Semaphore(permits = 50)

    suspend fun home(userId: String): Home = withTimeout(800.milliseconds) {   // overall budget
        coroutineScope {
            val p = async { profile.get(userId) }                 // required: failure fails the request
            val c = async { cart.get(userId) }
            val r = async { optional(recsBulkhead, 500.milliseconds) { recs.get(userId) } }
            val pr = async { optional(promosBulkhead, 300.milliseconds) { promos.get(userId) } }
            val n = async { optional(null, 300.milliseconds) { notifications.count(userId) } }
            Home(p.await(), c.await(), r.await(), pr.await(), n.await())
        }
    }

    /** Optional section: bounded concurrency, its own deadline, and null instead of failure. */
    private suspend fun <T> optional(bulkhead: Semaphore?, limit: Duration, call: suspend () -> T): T? =
        try {
            if (bulkhead != null && !bulkhead.tryAcquire()) return null  // full: fail fast, don't queue
            try { withTimeout(limit) { call() } } finally { bulkhead?.release() }
        } catch (e: TimeoutCancellationException) {
            null
        } catch (e: CancellationException) {
            throw e                                                       // the whole request was cancelled
        } catch (e: Exception) {
            log.warn("Optional section failed", e)
            null
        }
}
// The same shape with virtual threads (JDK 21+), without the preview StructuredTaskScope:
Home home(String userId) throws Exception {
    Instant deadline = Instant.now().plusMillis(800);
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        Future<Profile> p = executor.submit(() -> profile.get(userId));
        Future<Cart> c = executor.submit(() -> cart.get(userId));
        Future<List<Rec>> r = executor.submit(() -> withPermit(recsBulkhead, () -> recs.get(userId)));
        try {
            Profile profileResult = p.get(remaining(deadline), TimeUnit.MILLISECONDS);   // required
            Cart cartResult = c.get(remaining(deadline), TimeUnit.MILLISECONDS);
            List<Rec> recsResult = optional(r, Math.min(500, remaining(deadline)));       // null on failure
            return new Home(profileResult, cartResult, recsResult);
        } finally {
            List.of(p, c, r).forEach(f -> f.cancel(true));       // nothing outlives the request
        }
    }
}

The decisions:

  • One deadline for the request, propagated down: optional calls get shorter budgets so a slow optional section never pushes the response past 800 ms; a client that disconnects cancels everything (structured concurrency in Kotlin, explicit cancel(true) in Java).
  • Required vs optional is explicit in the code: required failures propagate and fail the request (and cancel siblings); optional ones become null, logged and counted in metrics.
  • Bulkheads per downstream: a semaphore with a few dozen permits and tryAcquire (fail fast, no queue). When recommendations slow down, at most 50 requests wait on it, and every other request renders immediately without recommendations, instead of all requests piling up behind one dependency.
  • Downstream clients also need their own timeouts (connect and read) below the section budget, and retries only where idempotent and only if the budget allows.
  • Lightweight concurrency doesn’t remove limits: the bulkheads and connection pools are the limits now; size them from each downstream’s capacity and watch their saturation metrics.
SwiftKotlinJava (21+)TypeScript
Parallel, dynamicwithThrowingTaskGroupmap { async { } }.awaitAll()futures + ExecutorCompletionServicePromise.all(items.map(work))
Timeouttask-group race helperwithTimeout, withTimeoutOrNullfuture.get(timeout) + cancel(true)AbortSignal.timeout(ms)
Fail fast + cancel siblingsdefault in task groupsdefault in coroutineScopemanual (or preview StructuredTaskScope)manual: Promise.all rejects, but you must abort() the rest
Limit concurrencystart tasks as others finishSemaphore, limitedParallelismjava.util.concurrent.Semaphorea small pool (e.g. p-limit)

Cancellation only helps if the work responds to it: long computations must check (Task.checkCancellation(), ensureActive(), Thread.interrupted(), signal.throwIfAborted()). Give the whole request one deadline and derive shorter budgets for optional calls.

Check this part

  1. A loop awaits download(url) for 20 images, one after another. Each takes 300 ms. How long until the gallery appears?

  2. How do you download them all at the same time and keep the order?

  3. You start five downloads and wait for all of them. One fails. What happens to the other four?

An await inside a loop waits for each item before starting the next. Start everything, then wait for everything, and stop the rest when one fails.

Part Not started

Shared state

Running work in parallel is the easy part. Sharing data between the parallel parts is where the bugs are:

Spot the bugA progress counter that loses count

Tap the line with the bug.

final class Stats { var processed = 0 }
let stats = Stats()
await withTaskGroup(of: Void.self) { group in
    for file in files {
        group.addTask {
            process(file)
            stats.processed += 1
        }
    }
}
var processed = 0
coroutineScope {
    files.forEach { file ->
        launch(Dispatchers.Default) {
            process(file)
            processed++
        }
    }
}
int[] processed = {0};
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Path file : files) {
        executor.submit(() -> {
            process(file);
            processed[0]++;
        });
    }
}
async function recordProcessed(store: Store) {
  const count = await store.get("processed");
  await store.set("processed", count + 1);
}

await Promise.all(files.map(async (file) => {
  await process(file);
  await recordProcessed(store);
}));

Never block the scheduler

Swift’s cooperative pool has about one thread per core and Kotlin’s Default dispatcher the same, and JavaScript has exactly one; all of them assume code suspends rather than blocks. A blocking call (a semaphore wait, synchronous file or network I/O, runBlocking) holds one of those few threads; enough of them, and the whole pool stalls, including the tasks that would have released the blocked ones. That’s a deadlock with no locks in sight (the second puzzle).

Blocking where you shouldn’t

Spot the bugAn app that freezes under load

Tap the line with the bug.

func loadSettings() async -> Settings {
    let semaphore = DispatchSemaphore(value: 0)
    var result: Settings?
    Task { result = await api.settings(); semaphore.signal() }
    semaphore.wait()
    return result!
}
suspend fun loadSettings(): Settings = withContext(Dispatchers.Default) {
    runBlocking {
        api.settings()
    }
}
private final Object lock = new Object();

Settings loadSettings() throws Exception {
    synchronized (lock) {
        HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
        return parse(response.body());
    }
}
app.get("/settings", (req, res) => {
  const raw = readFileSync("settings.json", "utf8");
  res.json(JSON.parse(raw));
});

Deep dives

Java

Deep diveVirtual threads and pinningInterview favorite

A virtual thread runs on a small pool of carrier platform threads and unmounts when it blocks, so millions can wait at once. Before Java 24, blocking inside a synchronized block pinned the virtual thread to its carrier: the carrier couldn’t run anything else, and a few pinned threads could exhaust the pool.

JEP 491 (Java 24) removed pinning for synchronized. On Java 21 to 23, use ReentrantLock around blocking calls in hot paths, and watch for jdk.VirtualThreadPinned events in JFR. Native code and some legacy libraries can still pin on any version.

Kotlin

Deep diveDispatchers.IO and limitedParallelismInterview favorite

Dispatchers.IO shares threads with Dispatchers.Default but allows up to 64 threads (or the number of cores, if higher) for blocking work. Sixty-four is a lot for a database pool of ten connections: the rest of the coroutines just block waiting for a connection.

Dispatchers.IO.limitedParallelism(10) creates a view with at most 10 threads in parallel, without creating a new pool, which matches blocking work to the resource it uses. With limitedParallelism(1), code between suspension points runs one piece at a time, which is enough to confine mutable state, though not always on the same thread. It limits threads, not coroutines: coroutines that suspend still interleave, so use a Semaphore to limit how many are in progress.

Swift

Deep diveMainActor.assumeIsolatedInterview favorite

Older APIs call delegate methods and completion handlers on the main thread, but don’t say so in a way Swift’s checks understand, so the compiler complains when they touch @MainActor state.

MainActor.assumeIsolated { … } tells the compiler “this synchronous code is already on the main actor” and checks it at runtime: if you’re wrong, it traps instead of creating a data race. It’s the honest escape hatch for legacy callbacks, unlike wrapping everything in Task { @MainActor in … }, which changes timing by running the code later.

TypeScript

Deep diveMicrotasks run before timersInterview favorite

After each piece of synchronous code, the event loop runs all queued microtasks (promise callbacks, the continuation after await) before the next macrotask (a setTimeout callback, an I/O event). So setTimeout(() => log("timer"), 0); Promise.resolve().then(() => log("promise")) prints “promise” first.

Two consequences come up in interviews: a promise chain that keeps scheduling more microtasks can starve timers and rendering, and a long synchronous loop blocks everything, including the promise continuations you think are “in the background”. CPU-heavy work belongs in a Worker.

SwiftKotlinJava (21+)TypeScript
Shared stateactor, Mutexatomics, Mutex, confinementatomics, ReentrantLock, concurrent collectionsone thread: no torn writes, but state can change at every await
Data-race safetycompile time (Swift 6: actors, Sendable)none (discipline)none (discipline)no shared memory between tasks (workers: SharedArrayBuffer + Atomics)

Swift

DispatchSemaphore.wait() (or any blocking call) inside async code holds one of a few cooperative-pool threads; enough of them and the app hangs.

Kotlin

runBlocking on a Default thread blocks it until the inner coroutine finishes; under load nothing is left to run those coroutines.

Java

On JDK 21–23, blocking I/O inside synchronized pins the carrier thread; use ReentrantLock, or no lock around I/O.

TypeScript

readFileSync, a long loop or a huge JSON.parse blocks the one event-loop thread: every request waits. Use async APIs and move CPU work to a worker.

Check this part

  1. 10,000 parallel tasks each do stats.processed += 1 on a shared class instance. What happens (in Swift 6 mode)?10,000 coroutines on Dispatchers.Default each do processed++ on a shared variable. What happens?10,000 virtual threads each do processed[0]++ on a shared array. What happens?10,000 async tasks each read a stored count, await something, then write count + 1. What happens?

  2. What's the most reliable fix?

  3. DispatchSemaphore.wait() inside an async function waits for a Task to signal. What happens under load?runBlocking { api.settings() } inside withContext(Dispatchers.Default). What happens under load?A virtual thread does HTTP I/O inside synchronized on JDK 21–23. What happens under load?A Node request handler reads a large file with readFileSync. What happens under load?

Parallel tasks that change shared state lose updates. Use an actor, an atomic or a lock, or better, let tasks return results; and never block the scheduler.

Key takeaways

  • Waiting shouldn’t block a thread: Swift await, Kotlin suspend, Java virtual threads and TypeScript await all free the thread while waiting.
  • Start independent work together, then wait for all of it; sequential awaits in a loop are slow.
  • Shared mutable state across parallel tasks needs an actor, an atomic or a lock, or better, tasks that return results.
  • Put deadlines on work and make sure cancelled work actually stops.
  • Swift and Kotlin make suspension explicit and concurrency structured by default; Java virtual threads scale blocking code but structure is still manual or preview.
  • Never block a cooperative scheduler (semaphores, runBlocking, synchronous I/O on Node’s event loop); avoid pinning on older JDKs.
  • Lightweight concurrency moves the bottleneck to pools and downstreams: add bulkheads, deadlines and load shedding.
  • Swift 6 catches data races at compile time; on the JVM it’s discipline: confinement, immutability, atomics and locks.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.