“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
// 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.
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.
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.
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.
| Question | Event loop | Scheduler |
|---|---|---|
| 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. |
// 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
});
} // 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
} 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.
Change the rule for “what runs next”.
Run one task to completion, then drain microtasks before taking the next task.
The queued microtask runs before the timer task, even when the timer delay is zero.
One JavaScript callback runs at a time on this loop.
A long microtask chain can starve timers, input, and rendering.
Assert the ordering of task, microtask, and timer boundaries—not wall-clock timing.
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.
// 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
});
} // 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
} 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.
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.
Where can work yield?
Name the synchronous turn, blocking send, await, or worker hand-off.
Who may write?
Make stale callbacks and concurrent updates unable to silently win.
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.
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.
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.
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.
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.
Keep the loop’s turns short.
Yield or move CPU work out when the callback cannot finish quickly.
Synchronize scheduled workers.
Make state ownership and message order explicit rather than relying on a trace.
Assert the contract.
Queue boundaries are testable; incidental scheduler order is not.
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
- Promises vs. goroutines & channels for the work and message model.
- Backpressure and queues for load that arrives faster than turns can finish.
- Race conditions in UI for late results that land on newer state.