Waiting and Continuing
You send a letter and wait for a reply. Do you stand frozen at the mailbox, unable to move until the response arrives? Or do you continue with your life, to be interrupted when the answer finally comes?
Most programs spend enormous amounts of time waiting: for files to load, for networks to respond, for databases to query, for users to click. The question is how they wait.
The Blocking World
The simplest approach: wait until done.
content = readFile("data.txt") // program freezes here
processed = process(content) // continues after file is read
save(processed) // freezes againThis is blocking (or synchronous) I/O. Each operation completes before the next begins. The thread is "blocked"---suspended, doing nothing---while waiting.
Blocking is simple and natural. The code reads like a recipe: do this, then that. But it has costs:
- A UI thread that blocks becomes unresponsive---the dreaded frozen application
- A server thread that blocks can't serve other requests
- A program waiting for a slow network wastes CPU cycles doing nothing
The Non-Blocking Alternative
What if, instead of waiting, we said "let me know when you're done"?
readFile("data.txt", whenDone: (content) => {
processed = process(content)
save(processed)
})
// This line runs IMMEDIATELY, before file is read
doOtherThings() This is non-blocking (or asynchronous) I/O. The readFile call returns immediately. The actual reading happens in the background. When complete, our callback is invoked with the result.
The program doesn't freeze. It continues doing other work, handling the file contents whenever they arrive.
Callbacks: The First Pattern
Callbacks are the oldest and most fundamental async pattern:
fetchUser(userId, (user) => {
// Called when user data arrives
displayProfile(user)
})
onClick(button, () => {
// Called when button is clicked
submitForm()
})
setTimeout(1000, () => {
// Called after 1 second
showReminder()
})The pattern: start an operation, provide a function to handle the result. The operation calls your function when ready.
Callback Hell
But callbacks have a dark side. What if you need to chain async operations?
readFile("config.txt", (config) => {
parseConfig(config, (settings) => {
connectDatabase(settings.db, (connection) => {
queryUsers(connection, (users) => {
filterActive(users, (active) => {
sendEmails(active, (results) => {
logResults(results, () => {
done()
})
})
})
})
})
})
})This is callback hell---nested callbacks that march ever rightward, forming a pyramid of doom. Each step depends on the previous, so each must nest inside the last.
Error handling makes it worse. Each callback needs its own error check, often duplicated at every level.
Promises: Values from the Future
A promise represents a value that will exist later. Instead of passing a callback, you receive an object that promises to eventually contain the result.
promise = readFile("data.txt")
// promise is not the content---it's a box that will contain the content
promise.then((content) => {
// Called when the content arrives
process(content)
})The power emerges in chaining:
readFile("config.txt")
.then((config) => parseConfig(config))
.then((settings) => connectDatabase(settings.db))
.then((connection) => queryUsers(connection))
.then((users) => filterActive(users))
.then((active) => sendEmails(active))
.then((results) => logResults(results))
.then(() => done())
.catch((error) => handleError(error)) The pyramid is gone. Each step is at the same indentation level. And error handling is centralized: one .catch() at the end handles errors from any step.
Promise States
A promise is always in one of three states:
- Pending
- — The operation is still in progress
- Fulfilled
- — The operation succeeded; the promise has a value
- Rejected
- — The operation failed; the promise has an error
promise = fetchData()
// Initially: pending
// Later, either:
// fulfilled with value
// rejected with error
promise
.then((value) => /* handle success */)
.catch((error) => /* handle failure */)Composing Promises
Promises compose. Run operations in parallel and wait for all:
Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchPreferences(userId)
]).then(([user, orders, prefs]) => {
// All three completed
displayDashboard(user, orders, prefs)
}) Notice that Promise.all itself returns a promise---a promise that contains all the results. This is the Composite pattern: a promise of promises is still a promise. You can nest them arbitrarily, and the interface remains the same.
Or race them and take the first:
Promise.race([
fetchFromServer1(data),
fetchFromServer2(data)
]).then((result) => {
// First one wins
})Async/Await: The Synchronous Illusion
Promises linearize callbacks, but the code still looks different from synchronous code. What if async code could look synchronous?
async function processData() {
config = await readFile("config.txt")
settings = await parseConfig(config)
connection = await connectDatabase(settings.db)
users = await queryUsers(connection)
active = await filterActive(users)
results = await sendEmails(active)
await logResults(results)
done()
} This looks exactly like the blocking version! But it's non-blocking. At each await, the function pauses, but the program continues running other code. When the awaited promise resolves, the function resumes.
Error handling uses familiar try/catch:
async function processData() {
try {
config = await readFile("config.txt")
settings = await parseConfig(config)
// ... rest of the chain
} catch (error) {
handleError(error)
}
}Coroutines: Pausable Functions
async/await is a specific case of a broader concept: coroutines---functions that can pause and resume.
coroutine generateNumbers() {
yield 1
yield 2
yield 3
}
gen = generateNumbers()
gen.next() // returns 1, pauses
gen.next() // returns 2, pauses
gen.next() // returns 3, pausesUnlike regular functions that run to completion, coroutines can suspend mid-execution and resume later.
Generators: Lazy Sequences
One powerful use of coroutines is generating values on demand:
coroutine fibonacci() {
a = 0
b = 1
while true:
yield a
(a, b) = (b, a + b)
}
fib = fibonacci()
fib.next() // 0
fib.next() // 1
fib.next() // 1
fib.next() // 2
fib.next() // 3
// ... infinite sequence, computed only as neededWithout coroutines, we'd need to store state externally:
// Stateful object approach
class FibonacciIterator {
a = 0
b = 1
next() {
result = a
(a, b) = (b, a + b)
return result
}
} The coroutine is more natural---the state lives in local variables, and yield marks where to pause.
Cooperative Multitasking
Coroutines enable multiple tasks to share a single thread by voluntarily yielding:
coroutine downloadFiles(urls) {
for url in urls:
startDownload(url)
yield // Let other coroutines run
content = getResult(url)
saveFile(content)
yield // Yield again
}
coroutine processUserInput() {
while true:
if hasInput():
handleInput(getInput())
yield // Let other coroutines run
}
// Scheduler runs both, interleaving at yield points
scheduler.run([downloadFiles(urls), processUserInput()])Async I/O with Coroutines
Coroutines shine for I/O-heavy code. The coroutine yields while waiting; the runtime resumes it when data arrives:
coroutine fetchAndProcess(url) {
response = yield fetch(url) // Pause until response
data = yield response.json() // Pause until parsed
result = transform(data)
yield save(result) // Pause until saved
return result
}Coroutines enable:
- Generators: producing values on demand (lazy sequences)
- Cooperative multitasking: multiple coroutines taking turns
- Async I/O: pausing while waiting for external resources
- State machines: complex control flow with natural syntax
The Event Loop
How does non-blocking I/O actually work? Most async systems use an event loop:
while true:
event = waitForNextEvent()
handler = findHandler(event)
handler(event)The loop:
- Waits for something to happen (I/O complete, timer fires, user clicks)
- Finds the callback registered for that event
- Runs the callback
- Repeats
This is why callbacks should be fast. A slow callback blocks the event loop, freezing everything.
Choosing a Pattern
When to use which?
Callbacks: Simple, one-off async operations. Event handlers (clicks, keypresses).
Promises: Chained async operations. When you need to compose or combine async work.
Async/await: Complex async logic with conditions and loops. When you want readable, maintainable code.
Coroutines: When you need fine-grained control over pausing and resuming. Generators, cooperative tasks.
| Pattern | Strength | Weakness |
|---|---|---|
| Callbacks | Simple, universal | Nesting, error handling |
| Promises | Chainable, composable | Still somewhat verbose |
| Async/await | Readable, familiar | Requires language support |
Asynchronous programming lets us wait without freezing. But it doesn't solve the deeper problem: what happens when multiple activities access the same data? The next chapter confronts the dangers of shared mutable state.