← Concepts & practices
Pattern Concurrency, scheduling, and delivery

Event loop vs. scheduler

Know what runs next, and what the runtime never promised.

A click queues a microtask and a timer. A CPU-heavy callback blocks its neighbors. Two workers update the same counter. “Async” does not answer what runs next: the runtime’s execution rule does. Compare the event loop’s turns with a scheduler’s possible interleavings, then design around the guarantees.

The judgment to keep

The event loop makes run-to-completion and queue priority visible. A scheduler makes runnable work compete for turns or cores. Treat incidental order as untrusted until a protocol, synchronization primitive, or documented runtime rule says otherwise.

TypeScriptGo One workload · two execution rules
Start with the question

“Async” is not a schedule.

Put a button, a timer, a network callback, and a CPU loop in one application. The code can look concurrent in both TypeScript and Go. The next callback or worker is still chosen by a runtime rule, and the rule changes what the programmer must protect.

On an event loop, one callback runs until it returns. In JavaScript, Promise reactions are microtasks; they drain after the current task and before the next timer or input task. This makes a short, useful invariant: another callback cannot interrupt the synchronous middle of this one.

A scheduler chooses among runnable tasks such as Go goroutines. It may interleave them, preempt them, or run them on different threads. Each worker’s own order remains meaningful; the global order is not unless synchronization establishes it.

The useful question is not “is this function async?” It is “what can run between these two lines, and what happens if it does?”

Read one event-loop turnTypeScript · task, microtask, timer
trace.ts · event-loop turn
// A real turn on this runtime's event loop. The click task runs to completion,
// then the microtask it queued drains, and only then can the zero-delay timer run.
export function eventLoopTrace(): Promise<string[]> {
	const trace: string[] = [];
	return new Promise((done) => {
		function click() {
			trace.push('task: click starts');
			queueMicrotask(() => trace.push('microtask: flush'));
			setTimeout(() => {
				trace.push('task: timer');
				done(trace);
			}, 0);
			trace.push('task: click ends');
		}
		setTimeout(click, 0); // dispatch the click as its own task
	});
}

The 0 in setTimeout means “not before this delay,” not “interrupt now.” The current task returns first, then the microtask queue drains, and only then can the timer task run.

Two rules

Run-to-completion or possible interleaving.

The event loop is a queue discipline. The scheduler is a set of choices among runnable work. Neither is “better” in the abstract: the event loop makes accidental shared-memory interleavings harder, while a scheduler lets CPU-bound siblings make progress and demands explicit synchronization.

Event loop

One turn at a time

A task runs to completion; queue priority decides what becomes eligible next.

Good at
Many waiting I/O operations and responsive short callbacks.
Protect
Turn duration, microtask starvation, and stale callback state.
Scheduler

Many runnable activities

Workers can interleave, block, wake, or run in parallel according to the runtime.

Good at
Independent work, CPU progress, and explicit message passing.
Protect
Shared state, shutdown, ordering, and every blocking hand-off.
What the runtime makes visible
QuestionEvent loopScheduler
Can work interrupt this callback?No, not during its synchronous turn.It may be interleaved or preempted.
What runs next?Queue discipline, including microtask priority.One of the runnable tasks; exact choice varies.
Can CPU work share progress?Not on the same loop while a callback runs.Potentially, across workers and cores.
What does shared state need?Turn ownership, but stale logic still needs design.Mutex, atomic operation, channel ownership, or another protocol.
How should tests assert order?Assert documented queue boundaries.Assert invariants and allowed outcomes, not a print order.
Event loop · microtask drain
trace.ts · event-loop turn
// A real turn on this runtime's event loop. The click task runs to completion,
// then the microtask it queued drains, and only then can the zero-delay timer run.
export function eventLoopTrace(): Promise<string[]> {
	const trace: string[] = [];
	return new Promise((done) => {
		function click() {
			trace.push('task: click starts');
			queueMicrotask(() => trace.push('microtask: flush'));
			setTimeout(() => {
				trace.push('task: timer');
				done(trace);
			}, 0);
			trace.push('task: click ends');
		}
		setTimeout(click, 0); // dispatch the click as its own task
	});
}
Scheduler · two goroutines, six allowed orders
trace.go · two goroutines, six allowed orders
// Two real goroutines each read, yield, then write. A mutex guards only the log,
// so the scheduler still chooses how the four steps interleave on each run.
func observeSchedule() []Operation {
	var mu sync.Mutex
	var log []Operation
	record := func(operation Operation) {
		mu.Lock()
		defer mu.Unlock()
		log = append(log, operation)
	}

	var workers sync.WaitGroup
	for _, steps := range [][]Operation{{AReads, AWrites}, {BReads, BWrites}} {
		workers.Add(1)
		go func() {
			defer workers.Done()
			for _, operation := range steps {
				record(operation)
				runtime.Gosched() // let the scheduler pick another runnable goroutine
			}
		}()
	}
	workers.Wait()
	return log
}

// Every order a scheduler may choose: each worker keeps its own read-before-write order.
func interleave(left, right []Operation) [][]Operation {
	if len(left) == 0 {
		return [][]Operation{slices.Clone(right)}
	}
	if len(right) == 0 {
		return [][]Operation{slices.Clone(left)}
	}
	var traces [][]Operation
	for _, tail := range interleave(left[1:], right) {
		traces = append(traces, append([]Operation{left[0]}, tail...))
	}
	for _, tail := range interleave(left, right[1:]) {
		traces = append(traces, append([]Operation{right[0]}, tail...))
	}
	return traces
}

func schedulerTraces() [][]Operation {
	return interleave([]Operation{AReads, AWrites}, []Operation{BReads, BWrites})
}

// Replaying one allowed order against a counter shows what it does to shared state.
func replay(trace []Operation) int {
	counter := 0
	seen := map[string]int{}
	for _, operation := range trace {
		worker, step, _ := strings.Cut(string(operation), " ")
		if step == "reads" {
			seen[worker] = counter
		} else {
			counter = seen[worker] + 1
		}
	}
	return counter
}
Trace the work

Change the runtime; keep the workload.

Choose a runtime model and a small workload. Read the trace as a contract: the event loop gives you task boundaries and microtask priority; the scheduler gives you a family of possible choices. The second model is deliberately not a claim about one observed run.

Same work, different runtime

Change the rule for “what runs next”.

Illustrative local trace
Event loop · Microtask and timer

Run one task to completion, then drain microtasks before taking the next task.

01 · taskclick starts and queues a microtask
02 · taskclick returns; the task is complete
03 · microtaskflush runs before the next task
04 · timerthe 0ms timer gets a turn
What runs next

The queued microtask runs before the timer task, even when the timer delay is zero.

Parallelism

One JavaScript callback runs at a time on this loop.

Risk

A long microtask chain can starve timers, input, and rendering.

Test it

Assert the ordering of task, microtask, and timer boundaries—not wall-clock timing.

The queue priority is the behavior; Promise syntax does not make the callback parallel.
The controls change a local trace; no timers, goroutines, or network calls run here.
Read the full tracesTypeScript and Go · real runs, the same printed output

One real turn: the click task returns, its microtask drains, then the timer runs. Go builds the same loop from one goroutine that owns the task queue.

TypeScriptReading
trace.ts
// A real turn on this runtime's event loop. The click task runs to completion,
// then the microtask it queued drains, and only then can the zero-delay timer run.
export function eventLoopTrace(): Promise<string[]> {
	const trace: string[] = [];
	return new Promise((done) => {
		function click() {
			trace.push('task: click starts');
			queueMicrotask(() => trace.push('microtask: flush'));
			setTimeout(() => {
				trace.push('task: timer');
				done(trace);
			}, 0);
			trace.push('task: click ends');
		}
		setTimeout(click, 0); // dispatch the click as its own task
	});
}
GoAlongside
trace.go
// Go has no built-in event loop, but one goroutine that owns a task queue runs
// like one: each task runs to completion, then the loop drains the microtasks
// that task queued before it takes the next task.
func eventLoopTrace() []string {
	tasks := make(chan func(), 4)
	var microtasks []func()
	var trace []string

	tasks <- func() { // the click task
		trace = append(trace, "task: click starts")
		microtasks = append(microtasks, func() { trace = append(trace, "microtask: flush") })
		tasks <- func() { // the zero-delay timer task
			trace = append(trace, "task: timer")
			close(tasks)
		}
		trace = append(trace, "task: click ends")
	}

	done := make(chan struct{})
	go func() { // the loop: one goroutine, one task at a time
		defer close(done)
		for task := range tasks {
			task()
			for len(microtasks) > 0 {
				next := microtasks[0]
				microtasks = microtasks[1:]
				next()
			}
		}
	}()
	<-done
	return trace
}
Predict before you run

Ask what can happen between the lines.

Good concurrency reasoning starts with a prediction. Name the runtime guarantee, then name the work that can still overlap, block, or arrive late. If the answer depends on a lucky schedule, the code needs a protocol.

A click queues Promise.then(...) and setTimeout(..., 0). Which callback runs first?
Two Go workers increment the same counter without synchronization.
A browser callback performs a large synchronous CPU loop.
Feedback stays on this page; it is not saved.
A production runtime

Turn runtime facts into application boundaries.

For an event-loop service, keep callbacks short, break up CPU work, and use explicit request identity when completions can arrive after state changes. A Promise makes the result composable; it does not make the work interruptible or parallel.

For a scheduled service, decide who owns each piece of state, who sends and receives each message, and what shutdown means. A goroutine that blocks without a receiver or a worker that writes shared state without synchronization is a lifetime bug before it is a performance bug.

Boundary

Where can work yield?

Name the synchronous turn, blocking send, await, or worker hand-off.

State

Who may write?

Make stale callbacks and concurrent updates unable to silently win.

Test

What is guaranteed?

Assert queue rules or invariants; leave incidental schedule order untested.

A button logs task completion, then shows the microtask before the zero-delay timer.

ReactAlready in your code
textbook.tsx · event-loop turn
export function ClickTrace() {
	function handleClick() {
		console.log('task: click starts');
		Promise.resolve().then(() => console.log('microtask: flush'));
		setTimeout(() => console.log('task: timer'), 0);
		console.log('task: click ends');
	}

	return <button onClick={handleClick}>Trace one turn</button>;
}
Build services or UIs?The runtime is already part of your design.

Where it already is in your components

A setTimeout used to yield, a Promise callback that updates a component, a Go worker pool, and a channel receive that gates shutdown all encode assumptions about what runs next.

When you have to own it

When a long callback makes input feel frozen, when two workers touch the same map, or when tests fail only under load, stop reading the syntax and draw the schedule. The missing boundary is often an ownership or synchronization decision.

Recognize it in UI code

Responsive UI is a scheduling contract.

Microtask queues

Promise reactions run after the current task and can delay timers when chained without yielding.

Rendering turns

Short event handlers give the browser a chance to process input and paint; a long synchronous loop takes that chance away.

Worker boundaries

Move CPU-heavy work to a worker when the main loop’s single-turn guarantee becomes a responsiveness problem.

See how late UI results become stale ↗
The parts to watch

The runtime cannot repair an unnamed protocol.

A zero-delay timer is still later

A timer asks to be eligible after a delay; it does not preempt the current callback or outrank the microtask queue. Use a timer or chunking to yield, not as a promise of an exact timestamp.

A Promise does not make CPU work non-blocking

Putting a loop inside an async function only changes how its result is returned. Once the synchronous loop starts, the event loop still waits for it to finish.

One lucky goroutine order proves nothing

The scheduler can choose a different runnable worker on another run or machine. Use channels, mutexes, atomics, or ownership transfer to establish the order your correctness depends on.

Avoid turning scheduling into a sleep test

A sleep can make a race less visible without making it safe. Test readiness with a signal and assert the invariant you need; reserve timing tests for an explicit timing contract.

Make the call

Design for guarantees, not vibes.

Choose an event loop when short turns and asynchronous I/O fit the workload and a single callback owner simplifies state. Choose scheduled workers when independent work, CPU progress, or explicit message passing matters, and pay for that power with synchronization and lifetime rules.

Need responsiveness

Keep the loop’s turns short.

Yield or move CPU work out when the callback cannot finish quickly.

Need shared progress

Synchronize scheduled workers.

Make state ownership and message order explicit rather than relying on a trace.

Need a test

Assert the contract.

Queue boundaries are testable; incidental scheduler order is not.

Take the idea with you

Every async boundary is a scheduling choice.

The event loop gives you turns and queue priority. A scheduler gives you runnable work and possible interleavings. Neither runtime can tell your application which state should win, when a child should stop, or how much work may be in flight. Those are protocols you still have to name. Once you can draw what runs next, the bug usually has a smaller owner.

Why
Make runtime scheduling assumptions visible.
What
Keep event-loop turns short; synchronize scheduled workers.
Constraint
Document which order is guaranteed and which order you only observed.
Fallback
When a turn runs long, chunk it or move it to a worker. When workers share state, keep the synchronization next to that state.
Reconsider when
The work becomes CPU-heavy, long-lived, or shared across owners.
Connections to follow nextRelated lessons
Explore more concepts & practices →