How we verify every timeline

A wrong thread assignment teaches a wrong mental model. So no timeline on this site is drawn from memory: each one is checked against a real program. Below is the latest run: every fact we check, its result on each JDK or compiler release, and what each program actually printed.

The latest run

2 Nov 2026, on GitHub Actions. 257 facts across 103 timelines, all passing on:

Refreshed every week. The data is written by the CI run itself and committed as-is; nobody edits it.

Every timeline

Open a timeline to see the facts its test checks and the program's real output, run on its own with the same settings as the tests.

Java

Basics

Starting a thread · start()3 facts
  • 21 ✓25 ✓main finishes first, download() runs on worker, and the JVM waits for it
  • 21 ✓25 ✓start() can only be called once
  • 21 ✓25 ✓a new Thread is NEW until start(), which runs it on its own thread

Real output, JDK 21

main goes on
download on worker
worker done
See the timeline
Starting a thread · run() by mistake1 facts
  • 21 ✓25 ✓run() executes download() on main, before main goes on; worker stays NEW

Real output, JDK 21

download on main
download done
main goes on
NEW
See the timeline
Starting a thread · daemon thread3 facts
  • 21 ✓25 ✓setDaemon must be called before start()
  • 21 ✓25 ✓executor threads are normal threads; common-pool threads are daemons
  • 21 ✓25 ✓the JVM exits when main ends, without waiting for the daemon or running its finally

Real output, JDK 21

main goes on
See the timeline
Stopping a thread · interrupt a sleeper2 facts
  • 21 ✓25 ✓Thread.stop() no longer works: it throws, or is gone from the JDK
  • 21 ✓25 ✓a sleeping worker is TIMED_WAITING, and interrupt() makes sleep throw at once

Real output, JDK 21

woken early, cleaning up
See the timeline
Stopping a thread · busy loop checks the flag2 facts
  • 21 ✓25 ✓a busy worker stays RUNNABLE; it stops once its loop checks the interrupt flag
  • 21 ✓25 ✓Thread.interrupted() reads the flag and clears it; isInterrupted() only reads

Real output, JDK 21

saw the flag, stopping
See the timeline
Stopping a thread · exception swallowed2 facts
  • 21 ✓25 ✓swallowing InterruptedException clears the flag, so the loop never ends
  • 21 ✓25 ✓restoring the flag in catch lets the same loop stop

Real output, JDK 21

(printed nothing)
[still running after 6 s, so we stopped it]
See the timeline
wait() vs sleep() · sleep() keeps the lock2 facts
  • 21 ✓25 ✓napper sleeps (TIMED_WAITING) with the lock, so other is BLOCKED until it leaves
  • 21 ✓25 ✓sleep() is static: it always sleeps the calling thread

Real output, JDK 21

other got the lock
See the timeline
wait() vs sleep() · wait() releases it3 facts
  • 21 ✓25 ✓wait() outside synchronized on that object throws IllegalMonitorStateException
  • 21 ✓25 ✓main prints waiter: ready
  • 21 ✓25 ✓wait() is WAITING without the lock; after notifyAll it is BLOCKED until the lock is free

Real output, JDK 21

waiter: ready
See the timeline

Shared state

Why count++ loses updates · count++3 facts
  • 21 ✓25 ✓two threads running count++ lose updatestrial 1: expected 2000000, got 1996541 (3459 lost)
  • 21 ✓25 ✓count++ compiles to separate read, add and write steps
  • 21 ✓25 ✓increment runs on T1 and T2, not on main

Real output, JDK 21

2

One run with one increment each usually prints 2: the race is rare. The facts test runs a million increments per thread, and the line under that fact is what it saw.

See the timeline
Why count++ loses updates · synchronized3 facts
  • 21 ✓25 ✓main prints 2
  • 21 ✓25 ✓T2 is BLOCKED while another thread holds the monitor, and enters once it is released
  • 21 ✓25 ✓no update is lost under contention

Real output, JDK 21

2
See the timeline
Why count++ loses updates · AtomicInteger3 facts
  • 21 ✓25 ✓main prints 2
  • 21 ✓25 ✓CAS fails and writes nothing when the value changed after it was read
  • 21 ✓25 ✓no update is lost, and the threads are never BLOCKED or WAITING while incrementing

Real output, JDK 21

2
See the timeline
Why a thread never sees your flag · plain field1 facts
  • 21 ✓25 ✓with a plain field, worker never sees stop = true and the program hangs

Real output, JDK 21

main set stop = true
[still running after 6 s, so we stopped it]
See the timeline
Why a thread never sees your flag · volatile1 facts
  • 21 ✓25 ✓with volatile, worker sees stop = true and the program exits

Real output, JDK 21

main set stop = true
worker stopped after 2538481483 spins
See the timeline
Deadlock · opposite lock order1 facts
  • 21 ✓25 ✓opposite lock order: both threads end up BLOCKED, and the JVM reports a deadlock

Real output, JDK 21

(printed nothing)
[still running after 6 s, so we stopped it]
See the timeline
Deadlock · same lock order2 facts
  • 21 ✓25 ✓same lock order: the second thread waits on A while holding nothing, then both finish
  • 21 ✓25 ✓main finishes with both threads done

Real output, JDK 21

T1 done
T2 done
See the timeline
Thread-safe collections · ArrayList1 facts
  • 21 ✓25 ✓two threads adding to one ArrayList lose elements or crashtrial 1: size 1894, crash null

Real output, JDK 21

2000
See the timeline
Thread-safe collections · synchronizedList2 facts
  • 21 ✓25 ✓no element is lost
  • 21 ✓25 ✓add() locks the list itself, so another thread's add is BLOCKED meanwhile

Real output, JDK 21

2000
See the timeline
Thread-safe collections · ConcurrentHashMap3 facts
  • 21 ✓25 ✓get-then-put on a ConcurrentHashMap still loses updates
  • 21 ✓25 ✓main prints {a=3, b=1}
  • 21 ✓25 ✓merge on another key runs alongside; merge on the same key is BLOCKED until the first is done

Real output, JDK 21

{a=3, b=1}
See the timeline
Why ConcurrentModificationException happens with one thread · remove in loop3 facts
  • 21 ✓25 ✓the remove itself works; the exception comes from the loop's next step
  • 21 ✓25 ✓one thread is enough: removing inside a for-each loop throws ConcurrentModificationException
  • 21 ✓25 ✓removing the second-to-last element doesn't throw: the loop ends early and never visits the last one

Real output, JDK 21

Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
	at threadline.java.concurrentmodification.Main.main(Main.java:10)
See the timeline
Why ConcurrentModificationException happens with one thread · removeIf2 facts
  • 21 ✓25 ✓removeIf removes bob without an exception and prints [ann, cid, dan]
  • 21 ✓25 ✓an explicit Iterator's own remove() works inside the loop too

Real output, JDK 21

[ann, cid, dan]
See the timeline
Why ConcurrentModificationException happens with one thread · another thread adds1 facts
  • 21 ✓25 ✓the exception is thrown in main, the thread that iterates, not in adder, the thread that added

Real output, JDK 21

main reads 1
main reads 2
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
	at threadline.java.concurrentmodificationthread.Main.main(Main.java:11)
See the timeline
Why ConcurrentModificationException happens with one thread · CopyOnWriteArrayList1 facts
  • 21 ✓25 ✓with CopyOnWriteArrayList there's no exception: the loop sees 1, 2, 3, and the list ends up with 99

Real output, JDK 21

main reads 1
main reads 2
main reads 3
[1, 2, 3, 99]
See the timeline

Executors

How a thread pool runs tasks · 2 threads2 facts
  • 21 ✓25 ✓4 tasks reuse the same 2 pool threads
  • 21 ✓25 ✓2 tasks run while the other 2 wait in the queue; submit() returns without waiting

Real output, JDK 21

task 2 on pool-1-thread-2
task 1 on pool-1-thread-1
task 3 on pool-1-thread-1
task 4 on pool-1-thread-2
See the timeline
How a thread pool runs tasks · 4 threads2 facts
  • 21 ✓25 ✓with 4 threads, all 4 tasks run at once and nothing waits in the queue
  • 21 ✓25 ✓each task gets its own pool thread

Real output, JDK 21

task 3 on pool-1-thread-3
task 4 on pool-1-thread-4
task 2 on pool-1-thread-2
task 1 on pool-1-thread-1
See the timeline
Future.get() makes you wait · get() late2 facts
  • 21 ✓25 ✓submit() returns at once; get() leaves the caller WAITING until the task finishes
  • 21 ✓25 ✓main prints the result

Real output, JDK 21

data
See the timeline
Future.get() makes you wait · get() too early1 facts
  • 21 ✓25 ✓get() right after submit() means doOtherWork only starts once slowCall is done

Real output, JDK 21

data
See the timeline
Why your Java program never exits (ExecutorService) · no shutdown()2 facts
  • 21 ✓25 ✓without shutdown(), all three tasks run, but the JVM never exits
  • 21 ✓25 ✓a fixed pool's threads are not daemons, so they keep the JVM alive

Real output, JDK 21

main returns
task 2 done
task 3 done
task 1 done
[still running after 6 s, so we stopped it]
See the timeline
Why your Java program never exits (ExecutorService) · shutdown()4 facts
  • 21 ✓25 ✓since JDK 19, try-with-resources closes the pool: close() waits for every task
  • 21 ✓25 ✓after shutdown(), submit() throws RejectedExecutionException
  • 21 ✓25 ✓after shutdown(), the three tasks still run, then the JVM exits
  • 21 ✓25 ✓shutdown() doesn't wait: it returns while tasks are still running

Real output, JDK 21

main returns
task 2 done
task 3 done
task 1 done
See the timeline
Why your Java program never exits (ExecutorService) · shutdownNow()3 facts
  • 21 ✓25 ✓the JVM exits right away instead of after the slow tasks
  • 21 ✓25 ✓a task that never checks for interrupts keeps running after shutdownNow()
  • 21 ✓25 ✓shutdownNow() interrupts the two running tasks and returns the one that never started

Real output, JDK 21

never started: 1
task 1 interrupted
task 2 interrupted
See the timeline

CompletableFuture

Which thread runs each step? · a pipeline3 facts
  • 21 ✓25 ✓main prints Ada
  • 21 ✓25 ✓stages run in order, all on the same common-pool thread, while the joiner WAITs
  • 21 ✓25 ✓common-pool threads are daemons, so main must join() or the JVM may exit early

Real output, JDK 21

Ada
See the timeline
Which thread runs each step? · thenApply vs thenApplyAsync4 facts
  • 21 ✓25 ✓thenApply attached before completion runs on the thread that completes the future
  • 21 ✓25 ✓thenApply on an already-completed future runs right away, on the caller
  • 21 ✓25 ✓thenApplyAsync with an executor runs on that executor's thread
  • 21 ✓25 ✓main shows parse on the caller, io-1 and cpu-1

Real output, JDK 21

parse(cached) on main
parse(fresh) on io-1
parse(fresh) on cpu-1
See the timeline
Combining futures · compose vs combine4 facts
  • 21 ✓25 ✓thenCompose: the second call starts only after the first finishes, and gets its result
  • 21 ✓25 ✓thenApply with a future-returning function gives a future inside a future
  • 21 ✓25 ✓thenCombine: both calls run at the same time, then the merge sees both results
  • 21 ✓25 ✓main prints the order and the page

Real output, JDK 21

Order[id=7] Ada, it's sunny
See the timeline
Combining futures · allOf fan-out4 facts
  • 21 ✓25 ✓if one call fails, allOf still waits for the rest before failing
  • 21 ✓25 ✓allOf completes only after the slowest call; then join() on each returns without waiting
  • 21 ✓25 ✓main prints the cheapest price, 9
  • 21 ✓25 ✓all three calls run at the same time

Real output, JDK 21

9
See the timeline
When a step throws · exceptionally / handle4 facts
  • 21 ✓25 ✓handle always runs: (value, null) on success, (null, error) on failure
  • 21 ✓25 ✓main prints -1 €12
  • 21 ✓25 ✓after a failure, thenApply is skipped and exceptionally recovers with -1
  • 21 ✓25 ✓exceptionally does not run on success

Real output, JDK 21

-1 €12
See the timeline
When a step throws · nobody handles it2 facts
  • 21 ✓25 ✓the exception is stored in the future: thenAccept is skipped and nothing is printed or logged
  • 21 ✓25 ✓the future did fail, with the exception wrapped

Real output, JDK 21

main moves on
See the timeline

Coordination

Wait for N things with CountDownLatch · all loaders succeed3 facts
  • 21 ✓25 ✓await() keeps the caller WAITING until the count reaches 0, not before
  • 21 ✓25 ✓main prints once everything is loaded
  • 21 ✓25 ✓a latch is single-use: it stays at 0 and can't be reset

Real output, JDK 21

all loaded, start serving
See the timeline
Wait for N things with CountDownLatch · a loader throws2 facts
  • 21 ✓25 ✓counting down in finally opens the latch even when a loader throws
  • 21 ✓25 ✓a loader that throws skips countDown(), so the count sticks at 1 and await() never returns

Real output, JDK 21

Exception in thread "config" java.lang.IllegalStateException: config file missing
	at threadline.java.latchloaderthrows.Main.load(Main.java:31)
	at threadline.java.latchloaderthrows.Main.lambda$startLoaders$0(Main.java:12)
	at java.base/java.lang.Thread.run(Thread.java:1583)
[still running after 6 s, so we stopped it]
See the timeline
Limit concurrency with a Semaphore · 2 permits2 facts
  • 21 ✓25 ✓main never has more than 2 threads inside callApi
  • 21 ✓25 ✓with 2 permits, 2 threads get in and 2 wait; each release lets exactly one more in

Real output, JDK 21

max inside callApi: 2
See the timeline
Limit concurrency with a Semaphore · 1 permit3 facts
  • 21 ✓25 ✓with 1 permit, only one worker is ever inside callApi
  • 21 ✓25 ✓a permit has no owner: another thread can release it
  • 21 ✓25 ✓a ReentrantLock does have an owner: unlock() from another thread throws IllegalMonitorStateException

Real output, JDK 21

max inside callApi: 1
See the timeline
Producer and consumer with a BlockingQueue · bounded (room for 2)3 facts
  • 21 ✓25 ✓jobs come out in the order they went in
  • 21 ✓25 ✓put() on a full queue leaves the producer WAITING until a take() frees a slot
  • 21 ✓25 ✓take() on an empty queue leaves the consumer WAITING until something is put

Real output, JDK 21

handled a
handled b
handled c
handled d
See the timeline
Producer and consumer with a BlockingQueue · unbounded3 facts
  • 21 ✓25 ✓with no limit, put() never waits: all 4 jobs pile up before anyone takes one
  • 21 ✓25 ✓newFixedThreadPool queues tasks in an unbounded LinkedBlockingQueue
  • 21 ✓25 ✓the consumer still handles every job, in order

Real output, JDK 21

handled a
handled b
handled c
handled d
See the timeline

Advanced

When parallelStream starves the common pool · default: the common pool4 facts
  • 21 ✓25 ✓by default the common pool has one thread fewer than there are CPU cores
  • 21 ✓25 ✓quickTask runs on a common-pool worker, and only after a report has finished
  • 21 ✓25 ✓the stream runs on main plus the 2 common-pool workers
  • 21 ✓25 ✓with 2 CPUs, parallelStream gets one worker, and supplyAsync uses a new thread per task on JDK 21 but the pool on JDK 25

Real output, JDK 21 (with -Djava.util.concurrent.ForkJoinPool.common.parallelism=2)

27 main report 4 start
46 ForkJoinPool.commonPool-worker-1 report 2 start
46 ForkJoinPool.commonPool-worker-2 report 6 start
346 ForkJoinPool.commonPool-worker-2 report 6 end
346 ForkJoinPool.commonPool-worker-2 report 5 start
347 main report 4 end
347 main report 1 start
347 ForkJoinPool.commonPool-worker-1 report 2 end
347 ForkJoinPool.commonPool-worker-1 report 3 start
647 ForkJoinPool.commonPool-worker-2 report 5 end
647 ForkJoinPool.commonPool-worker-2 quickTask
647 main report 1 end
647 ForkJoinPool.commonPool-worker-1 report 3 end
647 main stream done
648 http-1 got ok
See the timeline
When parallelStream starves the common pool · its own executor2 facts
  • 21 ✓25 ✓with its own executor, quickTask runs on io-1 before any report finishes
  • 21 ✓25 ✓a later *Async stage without an executor goes back to the common pool

Real output, JDK 21 (with -Djava.util.concurrent.ForkJoinPool.common.parallelism=2)

27 ForkJoinPool.commonPool-worker-2 report 6 start
49 main report 4 start
49 ForkJoinPool.commonPool-worker-1 report 2 start
167 io-1 quickTask
168 http-1 got ok
349 ForkJoinPool.commonPool-worker-1 report 2 end
350 ForkJoinPool.commonPool-worker-1 report 3 start
350 ForkJoinPool.commonPool-worker-2 report 6 end
350 ForkJoinPool.commonPool-worker-2 report 5 start
350 main report 4 end
350 main report 1 start
650 ForkJoinPool.commonPool-worker-1 report 3 end
650 ForkJoinPool.commonPool-worker-2 report 5 end
650 main report 1 end
650 main stream done
See the timeline
Virtual threads · virtual threads4 facts
  • 21 ✓25 ✓after sleep(), a virtual thread can resume on a different carrier
  • 21 ✓25 ✓sleeping inside synchronized pins the carrier before JDK 24, not from JDK 24 on
  • 21 ✓25 ✓1,000 sleeping virtual threads share at most 2 carrier threads
  • 21 ✓25 ✓main runs vt-1, vt-2, vt-3, each computing before and after its sleep

Real output, JDK 21

vt-2 on ForkJoinPool-1-worker-2
vt-3 on ForkJoinPool-1-worker-2
vt-1 on ForkJoinPool-1-worker-1
vt-2 on ForkJoinPool-1-worker-2
vt-3 on ForkJoinPool-1-worker-1
vt-1 on ForkJoinPool-1-worker-2
done
See the timeline
Virtual threads · 2 platform threads2 facts
  • 21 ✓25 ✓each task runs both halves on the same platform thread
  • 21 ✓25 ✓a sleeping task keeps its pool thread: the third task waits in the queue

Real output, JDK 21

task 1 on pool-1-thread-1
task 2 on pool-1-thread-2
task 2 on pool-1-thread-2
task 1 on pool-1-thread-1
task 3 on pool-1-thread-2
task 3 on pool-1-thread-2
done
See the timeline

Python

Choosing a model

threading vs multiprocessing vs asyncio · threading3 facts
  • 3.14 ✓3.15 ✓3.15t ✓both threads are in their fetch before either starts to crunch
  • 3.14 ✓3.15 ✓3.15t –with the GIL the two crunches use one CPU between themCPUs used: 1.00
  • 3.14 –3.15 –3.15t ✓on the free threaded build the two crunches run at the same timeCPUs used: 1.99

Real output, Python 3.14

a fetch
b fetch
a crunch
b crunch
b done
a done
See the timeline
threading vs multiprocessing vs asyncio · multiprocessing2 facts
  • 3.14 ✓3.15 ✓3.15t ✓both jobs fetch and crunch and every line is printed
  • 3.14 ✓3.15 ✓3.15t ✓the two crunches run in two worker processes at the same timeCPUs used: 2.00

Real output, Python 3.14

a fetch
b fetch
a crunch
b crunch
b done
a done
See the timeline
threading vs multiprocessing vs asyncio · asyncio1 facts
  • 3.14 ✓3.15 ✓3.15t ✓the fetches overlap then the crunches run one after the other

Real output, Python 3.14

a fetch
b fetch
a crunch
a done
b crunch
b done
See the timeline

Threads

Why Python threads don't run in parallel (the GIL) · CPU work4 facts
  • 3.14 ✓3.15 ✓3.15t ✓both threads finish
  • 3.14 ✓3.15 ✓3.15t –with the GIL two CPU bound threads use one CPU between themCPUs used: 1.00
  • 3.14 ✓3.15 ✓3.15t –with the GIL the threads take turns back and forth70 turns
  • 3.14 ✓3.15 ✓3.15t ✓a thread gets the GIL for 5 ms by default before it is asked to hand it over

Real output, Python 3.14

b done
a done
See the timeline
Why Python threads don't run in parallel (the GIL) · I/O work2 facts
  • 3.14 ✓3.15 ✓3.15t ✓both threads are waiting before either is done
  • 3.14 ✓3.15 ✓3.15t ✓a thread in time sleep does not hold the GIL so a CPU bound thread keeps runningthe busy thread ran 2087787 loop steps during the sleep

Real output, Python 3.14

a waits on the network
b waits on the network
a done
b done
See the timeline
Why Python threads don't run in parallel (the GIL) · python3.15t4 facts
  • 3.14 ✓3.15 ✓3.15t ✓both threads finish
  • 3.14 –3.15 –3.15t ✓this build has no GIL
  • 3.14 –3.15 –3.15t ✓without the GIL two CPU bound threads use two CPUs at onceCPUs used: 1.98
  • 3.14 –3.15 –3.15t ✓without the GIL two threads running count plus 1 lose updatestrial 1: expected 2000000, got 1943000 (57000 lost)

Real output, Python 3.14

b done
a done
See the timeline

Processes

multiprocessing.Pool: real parallelism with processes · Pool.map6 facts
  • 3.14 ✓3.15 ✓3.15t ✓each crunch runs in a worker process not in main
  • 3.14 ✓3.15 ✓3.15t ✓the two worker processes use two CPUs at onceCPUs used: 1.98
  • 3.14 ✓3.15 ✓3.15t ✓without the main guard the workers crash with RuntimeError and map never returns (spawn)
  • 3.14 ✓3.15 ✓3.15t ✓without the main guard the workers crash with RuntimeError and map never returns (forkserver)
  • 3.14 ✓3.15 ✓3.15t ✓a lambda cannot be sent to a worker PicklingError
  • 3.14 ✓3.15 ✓3.15t ✓the default start method is forkserver on Linux and spawn on macOS

Real output, Python 3.14

main process 2450
a ran in process 2454
b ran in process 2453
See the timeline
multiprocessing.Pool: real parallelism with processes · globals2 facts
  • 3.14 ✓3.15 ✓3.15t ✓main prints count in main 0
  • 3.14 ✓3.15 ✓3.15t ✓the workers did run bump but mains count never changes

Real output, Python 3.14

count in main: 0
See the timeline

Async

time.sleep vs asyncio.sleep: blocking the event loop · time.sleep2 facts
  • 3.14 ✓3.15 ✓3.15t ✓no tick runs while time sleep blocks the loop the ticks resume after
  • 3.14 ✓3.15 ✓3.15t ✓every task runs on one thread the one that called asyncio run

Real output, Python 3.14

tick 1
slow call starts
slow call ends
tick 2
tick 3
tick 4
tick 5
See the timeline
time.sleep vs asyncio.sleep: blocking the event loop · await asyncio.sleep1 facts
  • 3.14 ✓3.15 ✓3.15t ✓the ticker keeps ticking while the slow call waits4 ticks during the slow call

Real output, Python 3.14

tick 1
slow call starts
tick 2
tick 3
tick 4
tick 5
slow call ends
See the timeline
time.sleep vs asyncio.sleep: blocking the event loop · asyncio.to_thread2 facts
  • 3.14 ✓3.15 ✓3.15t ✓the ticker keeps ticking while the slow call waits4 ticks during the slow call
  • 3.14 ✓3.15 ✓3.15t ✓to thread runs the blocking call on another threadit ran on asyncio_0

Real output, Python 3.14

tick 1
slow call starts
tick 2
tick 3
tick 4
tick 5
slow call ends
See the timeline

JavaScript

The event loop

The JavaScript event loop: microtasks before timers · setTimeout vs Promise2 facts
  • 22 ✓24 ✓the whole script runs first, then the promise callback, then the timeout, even with a 0 ms delay
  • 22 ✓24 ✓a promise callback queued after a timer still runs before it

Real output, Node.js 22

script starts
script ends
promise
timeout
See the timeline
The JavaScript event loop: microtasks before timers · await1 facts
  • 22 ✓24 ✓an async function runs until its first await, then the rest comes back as a microtask, before the timeout

Real output, Node.js 22

script starts
load starts
script ends
load resumes
timeout
See the timeline
The JavaScript event loop: microtasks before timers · endless microtasks1 facts
  • 22 ✓24 ✓the timeout waits until all million microtasks have run

Real output, Node.js 22

timeout, after 1000000 microtasks
See the timeline

One thread

A CPU-heavy loop blocks the JavaScript event loop · busy loop2 facts
  • 22 ✓24 ✓no tick runs while the busy loop holds the JS thread; the ticks resume after
  • 22 ✓24 ✓making the function async doesn't help: its busy loop still blocks timers until it reaches an await

Real output, Node.js 22

tick
slow work starts
slow work ends
tick
tick
tick
tick
tick
tick
tick
tick
tick
See the timeline
A CPU-heavy loop blocks the JavaScript event loop · Worker3 facts
  • 22 ✓24 ✓the ticker keeps ticking while the worker runs the busy loop
  • 22 ✓24 ✓a Worker runs on its own thread, with its own threadId (main is 0)
  • 22 ✓24 ✓postMessage copies the data: the worker changing its copy leaves the main thread's object unchanged

Real output, Node.js 22

tick
slow work starts
tick
tick
tick
tick
tick
tick
tick
tick
tick
tick
tick
slow work ends
tick
tick
tick
tick
tick
tick
tick
See the timeline

Combining promises

Promise.all vs awaiting one by one · one by one1 facts
  • 22 ✓24 ✓b starts only after a ends

Real output, Node.js 22

a starts
a ends
b starts
b ends
a b
See the timeline
Promise.all vs awaiting one by one · Promise.all3 facts
  • 22 ✓24 ✓both calls start before either ends, and Promise.all returns the results in order
  • 22 ✓24 ✓if one promise rejects, Promise.all rejects right away, while the others are still running
  • 22 ✓24 ✓Promise.allSettled waits for every promise, and reports each one's result or error

Real output, Node.js 22

a starts
b starts
a ends
b ends
a b
See the timeline

C#

async and threads

Which thread runs the code after await? · await3 facts
  • 8 ✓10 ✓the code before the first await runs on the caller's thread
  • 8 ✓10 ✓after await Task.Delay, the code resumes on another thread, a thread-pool thread
  • 8 ✓10 ✓Main resumes on the same thread that finished FetchAsync

Real output, .NET 8

Main starts on thread 1
FetchAsync starts on thread 1
FetchAsync resumes on thread 5
Main resumes with data on thread 5
See the timeline
Which thread runs the code after await? · Task.Run3 facts
  • 8 ✓10 ✓Task.Run runs Crunch on a thread-pool thread, not on main
  • 8 ✓10 ✓after the await, Main continues on the thread that ran Crunch
  • 8 ✓10 ✓a thread busy running code reports ThreadState Running

Real output, .NET 8

Main starts on thread 1
Crunch runs on thread 4
Main resumes with 4999999950000000 on thread 4
See the timeline

Deadlocks

The .Result deadlock on a UI thread · .Result3 facts
  • 8 ✓10 ✓on a UI thread, .Result never returns: the program prints click and hangs
  • 8 ✓10 ✓the UI thread sits in .Result in state WaitSleepJoinUI thread state: Background, WaitSleepJoin
  • 8 ✓10 ✓the same .Result returns data where there is no SynchronizationContext, like a console app's thread pool

Real output, .NET 8

click
[still running after 6 s, so we stopped it]
See the timeline
The .Result deadlock on a UI thread · ConfigureAwait(false)2 facts
  • 8 ✓10 ✓with ConfigureAwait(false), .Result returns and the handler prints got data
  • 8 ✓10 ✓after ConfigureAwait(false), the rest of the method runs on a thread-pool thread, not the UI thread

Real output, .NET 8

click
got data
See the timeline
The .Result deadlock on a UI thread · await3 facts
  • 8 ✓10 ✓with await instead of .Result, the handler prints got data
  • 8 ✓10 ✓after await, OnClick continues on the UI thread
  • 8 ✓10 ✓while LoadAsync waits, the UI thread is free to run other work

Real output, .NET 8

click
got data
See the timeline

Combining tasks

Task.WhenAll vs awaiting one by one · one by one1 facts
  • 8 ✓10 ✓b starts only after a ends

Real output, .NET 8

a starts
a ends
b starts
b ends
a b
See the timeline
Task.WhenAll vs awaiting one by one · Task.WhenAll3 facts
  • 8 ✓10 ✓both fetches start before either ends, and WhenAll returns both results in order
  • 8 ✓10 ✓calling FetchAsync starts it right away: it runs until its first await before WhenAll is called
  • 8 ✓10 ✓if one task throws, await Task.WhenAll rethrows it, but only after every task has finished

Real output, .NET 8

a starts
b starts
a ends
b ends
a b
See the timeline

Kotlin

Coroutines and threads

Kotlin coroutines take turns on one thread · delay()2 facts
  • 2.4 ✓they take turns at each delay: a1 b1 a2 b2 a3 b3
  • 2.4 ✓both coroutines run on one thread, main

Real output, Kotlin 2.4

a step 1 on main
b step 1 on main
a step 2 on main
b step 2 on main
a step 3 on main
b step 3 on main
See the timeline
Kotlin coroutines take turns on one thread · Thread.sleep()1 facts
  • 2.4 ✓with Thread.sleep, a runs all three steps before b starts

Real output, Kotlin 2.4

a step 1 on main
a step 2 on main
a step 3 on main
b step 1 on main
b step 2 on main
b step 3 on main
See the timeline

Dispatchers

Dispatchers.IO vs Dispatchers.Default for blocking calls · Dispatchers.Default2 facts
  • 2.4 ✓with 2 CPUs, Dispatchers.Default runs at most 2 of the 4 blocking calls at once, on 2 threadsat most 2 at once, on [DefaultDispatcher-worker-1, DefaultDispatcher-worker-2]
  • 2.4 ✓all four calls finish, on threads named DefaultDispatcher-worker-N

Real output, Kotlin 2.4 (with -XX:ActiveProcessorCount=2)

call 1 done on DefaultDispatcher-worker-2
call 2 done on DefaultDispatcher-worker-1
call 3 done on DefaultDispatcher-worker-2
call 4 done on DefaultDispatcher-worker-1
See the timeline
Dispatchers.IO vs Dispatchers.Default for blocking calls · Dispatchers.IO3 facts
  • 2.4 ✓IO shares its threads with Default: they're named DefaultDispatcher-worker-N too
  • 2.4 ✓on Dispatchers.IO, all 4 blocking calls run at once, even with 2 CPUsat most 4 at once
  • 2.4 ✓Dispatchers.IO runs at most 64 blocking calls at once by defaultat most 64 at once

Real output, Kotlin 2.4 (with -XX:ActiveProcessorCount=2)

call 2 done on DefaultDispatcher-worker-3
call 3 done on DefaultDispatcher-worker-2
call 1 done on DefaultDispatcher-worker-1
call 4 done on DefaultDispatcher-worker-4
See the timeline

Cancellation

One failing coroutine cancels its siblings · coroutineScope3 facts
  • 2.4 ✓when a fails, b is cancelled, and coroutineScope rethrows a's exception
  • 2.4 ✓a coroutine that catches CancellationException without rethrowing keeps running after cancel
  • 2.4 ✓cancel() doesn't stop a busy loop with no suspension points: it runs to the endsteps: 445577 at cancel, 6368466 at the end

Real output, Kotlin 2.4

b cancelled
scope failed: a failed
See the timeline
One failing coroutine cancels its siblings · supervisorScope1 facts
  • 2.4 ✓in supervisorScope, a's failure goes to its handler, and b still finishes

Real output, Kotlin 2.4

handled: a failed
b done
scope done
See the timeline

Go

Basics

Why didn't my goroutines run? · no wait3 facts
  • 1.26 ✓1.27 ✓go worker(i) returns before the worker has done anythingworker 3 done worker 1 done worker 2 done
  • 1.26 ✓1.27 ✓while main runs on, each worker is in time.Sleep (state sleep)
  • 1.26 ✓1.27 ✓the program prints only main returns: no worker gets to print

Real output, Go 1.26

main returns
See the timeline
Why didn't my goroutines run? · wg.Wait()4 facts
  • 1.26 ✓1.27 ✓all three workers print before all done; their order can change
  • 1.26 ✓1.27 ✓main waits in wg.Wait (state sync.WaitGroup.Wait) while the workers sleep
  • 1.26 ✓1.27 ✓wg.Add inside the worker is too late: Wait can return before any worker ran
  • 1.26 ✓1.27 ✓since Go 1.25, wg.Go(f) does the Add and Done for you

Real output, Go 1.26

worker 2 done
worker 3 done
worker 1 done
all done
See the timeline

Shared state

Why count++ loses updates in goroutines · count++2 facts
  • 1.26 ✓1.27 ✓two goroutines running count++ lose updatestrial 1: expected 20000000, got 12551286 (7448714 lost)
  • 1.26 ✓1.27 ✓the race detector (go run -race) reports a DATA RACE on count

Real output, Go 1.26

2

One run with one increment each usually prints 2: the race is rare. The facts test runs a million increments per goroutine, and the line under that fact is what it saw.

See the timeline
Why count++ loses updates in goroutines · sync.Mutex4 facts
  • 1.26 ✓1.27 ✓two goroutines calling increment a million times each never lose an update
  • 1.26 ✓1.27 ✓the race detector finds no race
  • 1.26 ✓1.27 ✓while one goroutine holds mu, the other waits in mu.Lock (state sync.Mutex.Lock)
  • 1.26 ✓1.27 ✓a Go mutex has no owner: another goroutine may unlock it

Real output, Go 1.26

2
See the timeline
Why count++ loses updates in goroutines · atomic.Int642 facts
  • 1.26 ✓1.27 ✓two goroutines calling increment a million times each never lose an update
  • 1.26 ✓1.27 ✓the race detector finds no race

Real output, Go 1.26

2
See the timeline
fatal error: concurrent map writes, explained · plain map2 facts
  • 1.26 ✓1.27 ✓two goroutines writing one map crash the program: fatal error: concurrent map writes
  • 1.26 ✓1.27 ✓it's a fatal error, not a panic: recover() can't catch it

Real output, Go 1.26

fatal error: concurrent map writes

goroutine 19 [running]:
internal/runtime/maps.fatal({0x4c7fa8?, 0x0?})
	/opt/hostedtoolcache/go/1.26.10/x64/src/runtime/panic.go:1181 +0x18
main.main.func1()
	/home/runner/work/threadline/threadline/verify/go/concurrent-map-writes/main.go:17 +0x85
created by main.main in goroutine 1
	/home/runner/work/threadline/threadline/verify/go/concurrent-map-writes/main.go:14 +0x3e
See the timeline
fatal error: concurrent map writes, explained · sync.Mutex3 facts
  • 1.26 ✓1.27 ✓with a mutex around each write, no crash and no lost count: 200000 in each key
  • 1.26 ✓1.27 ✓the race detector finds no race
  • 1.26 ✓1.27 ✓a read without the lock, during a locked write, still crashes: fatal error: concurrent map read and map write

Real output, Go 1.26

10 keys, 200000 in key 0
See the timeline

Channels

Does a channel send wait for the receiver? · unbuffered3 facts
  • 1.26 ✓1.27 ✓while the receiver is busy, main waits at ch <- (state chan send)
  • 1.26 ✓1.27 ✓the send finishes only after the receiver has reached <-ch
  • 1.26 ✓1.27 ✓the receiver gets hello and the program ends normally

Real output, Go 1.26

received hello
sent
See the timeline
Does a channel send wait for the receiver? · buffer of 13 facts
  • 1.26 ✓1.27 ✓ch <- returns right away: the value waits in the buffer while the receiver sleeps
  • 1.26 ✓1.27 ✓a second send, with the buffer full, waits (state chan send)
  • 1.26 ✓1.27 ✓the program prints sent before received hello

Real output, Go 1.26

sent
received hello
See the timeline
A worker pool on a channel · close(jobs)5 facts
  • 1.26 ✓1.27 ✓every job runs exactly once, then main prints all jobs done
  • 1.26 ✓1.27 ✓both workers take jobs
  • 1.26 ✓1.27 ✓the sends never wait: the buffer holds all 4 jobs
  • 1.26 ✓1.27 ✓an idle worker waits in range (state chan receive) until a job or close arrives
  • 1.26 ✓1.27 ✓sending on a closed channel panics: send on closed channel

Real output, Go 1.26

worker 1 did job 2
worker 2 did job 1
worker 2 did job 4
worker 1 did job 3
all jobs done
See the timeline
A worker pool on a channel · forgot close3 facts
  • 1.26 ✓1.27 ✓all 4 jobs still get done
  • 1.26 ✓1.27 ✓then Go crashes with fatal error: all goroutines are asleep - deadlock!
  • 1.26 ✓1.27 ✓the dump shows main in wg.Wait and both workers waiting in range

Real output, Go 1.26

worker 1 did job 2
worker 2 did job 1
worker 2 did job 4
worker 1 did job 3
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x7b41cc84f10?, 0x1?)
	/opt/hostedtoolcache/go/1.26.10/x64/src/runtime/sema.go:114 +0x2e
sync.(*WaitGroup).Wait(0x7b41cc22110)
	/opt/hostedtoolcache/go/1.26.10/x64/src/sync/waitgroup.go:206 +0x85
main.main()
	/home/runner/work/threadline/threadline/verify/go/worker-pool-no-close/main.go:28 +0x105

goroutine 7 [chan receive]:
main.worker(0x1, 0x7b41cc38090, 0x0?)
	/home/runner/work/threadline/threadline/verify/go/worker-pool-no-close/main.go:12 +0x13e
created by main.main in goroutine 1
	/home/runner/work/threadline/threadline/verify/go/worker-pool-no-close/main.go:23 +0x4f

goroutine 8 [chan receive]:
main.worker(0x2, 0x7b41cc38090, 0x0?)
	/home/runner/work/threadline/threadline/verify/go/worker-pool-no-close/main.go:12 +0x13e
created by main.main in goroutine 1
	/home/runner/work/threadline/threadline/verify/go/worker-pool-no-close/main.go:23 +0x4f
See the timeline
A select timeout that leaks a goroutine · time.After4 facts
  • 1.26 ✓1.27 ✓the timeout wins: main prints timed out
  • 1.26 ✓1.27 ✓long after, slowCall's goroutine is still alive (2 goroutines, not 1)
  • 1.26 ✓1.27 ✓it is stuck at result <- forever (state chan send): nobody will ever receive
  • 1.26 ✓1.27 ✓while it waits, main is in select (state select)

Real output, Go 1.26

timed out
goroutines: 2
See the timeline
A select timeout that leaks a goroutine · buffer of 12 facts
  • 1.26 ✓1.27 ✓main still times out, and later only main is left (1 goroutine)
  • 1.26 ✓1.27 ✓the late send lands in the buffer, so slowCall returns

Real output, Go 1.26

timed out
goroutines: 1
See the timeline
A select timeout that leaks a goroutine · context2 facts
  • 1.26 ✓1.27 ✓main prints timed out: context deadline exceeded, and later only main is left
  • 1.26 ✓1.27 ✓slowCall stops as soon as ctx is cancelled, before its call finishes, and sends nothing

Real output, Go 1.26

timed out: context deadline exceeded
goroutines: 1
See the timeline

Cancellation

Stopping goroutines with context cancellation · checks ctx4 facts
  • 1.26 ✓1.27 ✓one cancel() stops all three workers
  • 1.26 ✓1.27 ✓between units of work, a worker waits in select (state select)
  • 1.26 ✓1.27 ✓cancelling a parent context cancels every context made from it
  • 1.26 ✓1.27 ✓after cancel(), ctx.Err() is context canceled

Real output, Go 1.26

worker 2 stopped
worker 3 stopped
worker 1 stopped
See the timeline
Stopping goroutines with context cancellation · ignores ctx2 facts
  • 1.26 ✓1.27 ✓cancel() stops nothing: no worker ever stops, and the program never ends
  • 1.26 ✓1.27 ✓Go doesn't report a deadlock either: the sleeping workers are not stuck, just ignoring ctx

Real output, Go 1.26

(printed nothing)
[still running after 6 s, so we stopped it]
See the timeline

Rust

Threads

Sharing a counter between threads with Arc<Mutex<T>> · short lock7 facts
  • 1.99 ✓the program prints 2
  • 1.99 ✓two threads adding a million times each never lose an update
  • 1.99 ✓while one thread holds the lock the other waits and adds only after the guard is dropped
  • 1.99 ✓a plain mut counter shared with a thread does not compile error E0373
  • 1.99 ✓an Rc counter does not compile either Rc cannot be sent between threads E0277
  • 1.99 ✓with move and no Arc the thread changes its own copy and main still sees 0
  • 1.99 ✓if a thread panics while holding the lock the next lock returns an error poisoned

Real output, Rust 1.99

2
See the timeline
Sharing a counter between threads with Arc<Mutex<T>> · lock held during work3 facts
  • 1.99 ✓the program still prints 2
  • 1.99 ✓holding the guard during slow work makes the threads take turns
  • 1.99 ✓dropping the guard before slow work lets both threads work at once

Real output, Rust 1.99

2
See the timeline

Async

Tokio tasks switch only at .await · with .await2 facts
  • 1.99 ✓each task prints its steps in order and the tasks take turns
  • 1.99 ✓every task runs on one thread the one that started the runtime

Real output, Rust 1.99

a step 1
b step 1
b step 2
a step 2
a step 3
b step 3
See the timeline
Tokio tasks switch only at .await · no .await1 facts
  • 1.99 ✓without await task a runs all its steps before b starts

Real output, Rust 1.99

a step 1
a step 2
a step 3
b step 1
b step 2
b step 3
See the timeline
Blocking in async code stalls every task · std::thread::sleep1 facts
  • 1.99 ✓no tick runs while the slow call blocks the thread the ticks resume after

Real output, Rust 1.99

tick 1
slow call starts
slow call ends
tick 2
tick 3
tick 4
tick 5
See the timeline
Blocking in async code stalls every task · spawn_blocking2 facts
  • 1.99 ✓the ticker keeps ticking while the slow call runs4 ticks during the slow call
  • 1.99 ✓spawn blocking runs the closure on another thread from the blocking poolthe closure ran on tokio-rt-worker

Real output, Rust 1.99

tick 1
slow call starts
tick 2
tick 3
tick 4
tick 5
slow call ends
See the timeline
Blocking in async code stalls every task · sleep().await1 facts
  • 1.99 ✓the ticker keeps ticking while main awaits its sleep4 ticks during the sleep

Real output, Rust 1.99

tick 1
slow call starts
tick 2
tick 3
tick 4
tick 5
slow call ends
See the timeline
join! vs awaiting one by one · one by one1 facts
  • 1.99 ✓b starts only after a ends

Real output, Rust 1.99

a starts
a ends
b starts
b ends
a b
See the timeline
join! vs awaiting one by one · join!3 facts
  • 1.99 ✓both fetches start before either ends
  • 1.99 ✓join runs both futures on the callers thread not on the runtimes worker threads
  • 1.99 ✓Tokio spawn instead runs a task on a worker threadthe task ran on tokio-rt-worker

Real output, Rust 1.99

a starts
b starts
b ends
a ends
a b
See the timeline
future cannot be sent between threads safely, explained · drop the guard first4 facts
  • 1.99 ✓the program prints 2
  • 1.99 ✓holding a std MutexGuard across await in a spawned task does not compile future cannot be sent between threads safely
  • 1.99 ✓dropping the guard before the await compiles
  • 1.99 ✓a spawned task can resume on a different worker thread after an await8 tasks ran on more than one thread

Real output, Rust 1.99

2
See the timeline
future cannot be sent between threads safely, explained · tokio::sync::Mutex2 facts
  • 1.99 ✓the program prints 2
  • 1.99 ✓the second task waits in lock await until the first drops its guard

Real output, Rust 1.99

2
See the timeline

A runnable twin for every timeline

Every scenario and every what-if variant has a Java, Python, JavaScript, C#, Kotlin, Go or Rust program that does exactly what the timeline shows, with the same code lines, in the same order. A test fails the build if a timeline has no twin, or if its code drifts from the twin.

Facts, not timings

Threads are scheduled differently on every machine, so the tests never check how long something took. They check facts that must hold on any machine: which thread ran a callback, which thread was BLOCKED while another held a lock, which goroutine a goroutine dump shows in [chan send], that two Python threads never use more than one CPU between them while the GIL is on, that Rust refuses to compile a shared counter without a Mutex. Gates and latches force the interesting interleaving, so a test can't pass by luck.

For example, "a non-async thenApply runs on the thread that completed the future":

var f = CompletableFuture.supplyAsync(() -> {
    Facts.await(gate);
    return "fresh";
}, ioPool);
var ranOn = f.thenApply(r -> Thread.currentThread().getName());
gate.countDown();
assertEquals("io-1", ranOn.join());

Two versions where it matters

GitHub Actions runs every test on every change, and again every week: Java on JDK 21 and 25, Python on Python 3.14, 3.15 and free-threaded 3.15t, JavaScript on Node.js 22 and 24, C# on .NET 8 and 10, Kotlin on Kotlin 2.4 with kotlinx.coroutines 1.11, Go on Go 1.26 and 1.27, Rust on Rust 1.99 with Tokio 1.54. Some behavior really differs between versions: a virtual thread blocking inside synchronized is pinned to its carrier on JDK 21 but not on JDK 25 (JEP 491), and the tests check both.

Does your Java version matter?

Mostly not. In October 2026 we ran every program on JDK 8, 11, 17, 21, 25 and 27. Every program that compiles on a version behaved the same there, apart from three things:

JDK 8, 11, 17JDK 21JDK 25JDK 27
Thread.stop()Kills the thread (up to JDK 19)Throws UnsupportedOperationException (from JDK 20)Removed
supplyAsync() with 2 CPUsA new thread per taskThe common pool's single thread
Virtual thread blocking in synchronized or wait()No virtual threadsPins its carrierFrees its carrier

The scenarios these affect say so in their notes.

Gotchas are claims too

The warnings under each timeline are tested as well: that newFixedThreadPool's queue is unbounded, that throwing InterruptedException clears the interrupt flag, that go run -race reports the lost update, that a Rust mutex is poisoned after a panic.

About Threadline

Threadline is an independent site that teaches concurrency by showing which thread runs what. It's free, with no sign-up and no ads. We count page views, without cookies, to see which scenarios people use.

Back to the scenarios