You Don't Know JS: Async & Performance
Deep Dive into Async & Performance in JavaScript.
Fourth book review and the third of the series YDKJS. This book is a deep dive into the concepts of async and performance in JavaScript. Im glad i didn’t have to program in the callback hell of the past 😅.
Chapter 1: Asynchrony: Now and Later
Understanding Asynchrony: Code That Runs Now vs. Later
One of the first hurdles when learning asynchronous JavaScript is grasping that not all code runs sequentially, top-to-bottom, right when you expect it. The book “You Don’t Know JS: Async & Performance” introduces this beautifully with the concept of code executing “Now” and code being scheduled to execute “Later”.
Let’s look at a super simple example:
console.log("Program Start: Executing NOW"); // Part 1: Now
// Schedule a function to run 'later'
setTimeout(function taskForLater() {
// This function body is the 'later' part
console.log("Inside setTimeout: Executing LATER"); // Part 3: Later
}, 0); // We use 0ms delay
console.log("After setTimeout call: Also executing NOW"); // Part 2: Now
console.log("Program End: Executing NOW"); // Part 4: Now
What Do You Expect?
If JavaScript only ran synchronously (top-to-bottom, one line at a time), you might expect the output to include the “Inside setTimeout” message right after “Program Start” or maybe after the setTimeout
call line, especially since we used a delay of (0) milliseconds.
The Actual Output (“Now” vs. “Later”)
However, if you run this code (in a browser console or Node.js), you’ll see this:
Program Start: Executing NOW
After setTimeout call: Also executing NOW
Program End: Executing NOW
Inside setTimeout: Executing LATER
Why Does This Happen?
This demonstrates the core idea of “Now” vs. “Later”:
- Now: The main part of your program runs immediately.
console.log("Program Start...")
, the call tosetTimeout
,console.log("After setTimeout call...")
, andconsole.log("Program End...")
all execute sequentially as part of the initial “tick” of the JavaScript engine. - Later: When
setTimeout
is called, it doesn’t execute the function (taskForLater
) immediately. Instead, it tells the JavaScript environment (like the browser) “Hey, schedule thistaskForLater
function to run after the current chunk of code finishes, and after at least (0) milliseconds have passed.” - The Event Loop: The environment places
taskForLater
onto a queue (often called the “task queue” or “callback queue”). Only when the main program thread is empty (after “Program End” has logged) does the JavaScript event loop check this queue. It seestaskForLater
waiting, picks it up, and executes it. This is the “Later” part.
Even with a (0)ms delay, setTimeout
always schedules the function for later, ensuring the currently running synchronous code finishes first.
Key Takeaway
This simple example highlights that asynchronous operations like setTimeout
(and others like event listeners, Promises, fetch
calls) split your program’s execution into two phases: the part that runs now, and the part(s) scheduled to run later, managed by the event loop. Understanding this split is fundamental to mastering async JavaScript, as detailed throughout YDKJS: Async & Performance.
Chapter 2: Callbacks - The Foundation of “Later”
In the previous section, we saw how asynchronous operations split our code into parts that run “Now” and parts scheduled for “Later”. But how does JavaScript actually handle executing that “Later” code? The most fundamental mechanism, and the focus of Chapter 2 in YDKJS: Async & Performance, is the callback function.
What Exactly is a Callback?
At its core, a callback is simply a function that you pass as an argument to another function. The intention is that the receiving function will execute (“call back”) your function at some point in the future – the “Later” part of our execution model.
Let’s revisit our setTimeout
example, but be more explicit about the callback:
console.log("Program Start: NOW"); // Now
// Define the function we want to run later
function taskForLater() {
console.log("Callback executed: LATER"); // Later
}
// Schedule taskForLater to be called back after (at least) 0ms
// taskForLater is the *callback* function here.
setTimeout(taskForLater, 0);
console.log("setTimeout scheduled: NOW"); // Now
console.log("Program End: NOW"); // Now
// Expected Output:
// Program Start: NOW
// setTimeout scheduled: NOW
// Program End: NOW
// Callback executed: LATER
Here, taskForLater
is the callback. We don’t execute it directly; we hand it over to setTimeout
, trusting it to execute our function later.
Callbacks are ubiquitous in classic JavaScript and Node.js:
- Event handling in browsers:
button.addEventListener('click', function handleClick() { /* ... */ });
(handleClick
is the callback). - Node.js core modules (like the
fs
module):fs.readFile('myFile.txt', function handleFile(err, data) { /* ... */ });
(handleFile
is the callback). - Older AJAX requests (
XMLHttpRequest
).
The Trouble with Callbacks: Part 1 - Callback Hell
While callbacks enable asynchronicity, relying solely on them can lead to problems, especially when you need to perform multiple asynchronous operations in sequence. Each subsequent operation often depends on the result of the previous one, requiring nested callbacks.
Imagine needing to:
- Fetch user data.
- Then, use that data to fetch user permissions.
- Then, use those permissions to fetch user posts.
This often results in code structured like this (pseudo-code):
// --- Pseudo-code Example ---
ajax("/api/user", function handleUser(userData) {
// Runs LATER (after user data arrives)
console.log("Got user data");
ajax("/api/permissions?id=" + userData.id, function handlePerms(permsData) {
// Runs EVEN LATER (after permissions arrive)
console.log("Got permissions");
ajax("/api/posts?perms=" + permsData.key, function handlePosts(postsData) {
// Runs YET EVEN LATER (after posts arrive)
console.log("Got posts");
// ... finally do something with postsData
});
console.log("Requesting posts..."); // Runs LATER (but before posts arrive)
});
console.log("Requesting permissions..."); // Runs LATER (but before perms arrive)
});
console.log("Requesting user data..."); // Runs NOW
// --- End Pseudo-code Example ---
This deeply nested structure is famously known as “Callback Hell” or the “Pyramid of Doom”. It suffers from:
- Poor Readability: The horizontal drift makes the code hard to follow.
- Difficult Reasoning: Tracking control flow and error handling through nested callbacks is complex.
- Maintenance Issues: Modifying or debugging this structure is brittle and error-prone.
The Trouble with Callbacks: Part 2 - Inversion of Control (IoC)
YDKJS argues that Callback Hell is just a surface problem. The deeper, more fundamental issue with callbacks is Inversion of Control (IoC).
When you pass your callback function (like taskForLater
or handleUser
) to another utility (like setTimeout
or ajax
), you are inverting the control. Instead of your code calling the function directly when you decide, you are handing control over to the other party, trusting them to execute your function correctly.
As Kyle Simpson puts it, you’re implicitly trusting this external function/utility to:
- Call your callback (reliably, not forgetting it).
- Call it only once (not multiple times, not zero times).
- Call it with appropriate arguments (the data or error it’s supposed to pass).
- Call it with the correct execution context (the
this
keyword, if relevant). - Handle errors that might occur within the utility itself.
- Not suppress errors that might happen inside your callback.
This trust is often implicit and can be fragile. If the utility you pass your callback to has bugs or behaves unexpectedly, it directly impacts your program’s logic in ways that can be very hard to debug. You’ve given away control over a piece of your own program’s execution!
Chapter 3: Promises - A Trustworthy Future Value
In the last chapter, we saw the limitations of callbacks: the dreaded “Callback Hell” and the more fundamental issue of “Inversion of Control.” We needed a better way to manage asynchronous operations, something that offered more predictability and control. Enter Promises.
As YDKJS: Async & Performance explains, a Promise isn’t just a callback mechanism; it’s a placeholder for a future value. Think of it as an IOU – it represents the eventual result of an asynchronous operation, which might be a success value or a reason for failure.
The Life Cycle of a Promise
A Promise exists in one of three mutually exclusive states:
- Pending: The initial state. The asynchronous operation is still ongoing; the final value isn’t ready yet.
- Fulfilled (or Resolved): The operation completed successfully. The promise now holds its resulting value.
- Rejected: The operation failed. The promise now holds the reason for the failure (usually an Error object).
Once a promise is either fulfilled or rejected, it’s considered settled. A crucial guarantee of Promises is that they can only settle once. Their state and resulting value (or reason) become immutable after settlement. This immutability is key to their trustworthiness.
Interacting with Promises: .then()
So, how do we get the value out of a promise once it settles? We use the .then()
method.
The .then()
method takes up to two arguments:
- The first argument is a function (a callback!) to be executed if the promise fulfills. It receives the fulfillment value.
- The second argument is a function (also a callback!) to be executed if the promise rejects. It receives the rejection reason.
let myPromise = fetchDataFromServer(); // Assume this returns a Promise
console.log("Promise created (likely pending): NOW"); // Now
myPromise.then(
// Fulfillment handler (runs LATER, if promise fulfills)
function handleSuccess(data) {
console.log("Promise Fulfilled: LATER");
console.log("Data:", data);
},
// Rejection handler (runs LATER, if promise rejects)
function handleError(error) {
console.error("Promise Rejected: LATER");
console.error("Reason:", error);
}
);
console.log("Promise handler attached: NOW"); // Now
Notice that we’re still using callbacks here, but the way we use them is different. We aren’t passing them into the fetchDataFromServer
function; we’re attaching them to the return value (the promise itself).
Solving Callback Hell: Promise Chaining
The real magic happens because .then()
itself returns a new promise. This allows us to chain asynchronous operations sequentially without the deep nesting of Callback Hell.
Let’s revisit our previous pseudo-code example, now using Promises:
// --- Promise Chaining Example ---
ajax("/api/user") // Assume ajax returns a Promise
.then(function handleUser(userData) {
// Runs LATER (after user data arrives)
console.log("Got user data");
// Return a *new* promise for the next step
return ajax("/api/permissions?id=" + userData.id);
})
.then(function handlePerms(permsData) {
// Runs EVEN LATER (after permissions arrive)
console.log("Got permissions");
// Return another promise
return ajax("/api/posts?perms=" + permsData.key);
})
.then(function handlePosts(postsData) {
// Runs YET EVEN LATER (after posts arrive)
console.log("Got posts");
// ... finally do something with postsData
})
.catch(function handleError(error) {
// Runs LATER if *any* preceding promise rejects
console.error("An error occurred in the chain:", error);
});
console.log("Requesting user data (chain initiated): NOW"); // Runs NOW
// --- End Promise Chaining Example ---
Look at that! The code flows linearly, top-to-bottom, reflecting the sequence of asynchronous steps. Each .then()
waits for the previous promise to fulfill before executing its handler.
Error Handling with .catch()
While you can handle errors with the second argument to .then()
, it’s more common and often clearer to use the .catch(rejectionHandler)
method. It’s essentially syntactic sugar for .then(null, rejectionHandler)
. A single .catch()
at the end of a chain can handle a rejection from any of the preceding promises, simplifying error management significantly.
Addressing Inversion of Control
How do Promises fix the IoC problem we discussed with callbacks?
- Control: You don’t pass your continuation logic into the async-initiating function. Instead, you receive a promise from it and attach your logic via
.then()
. You observe the promise’s outcome rather than handing over control of your function. - Trust: Promises come with built-in, reliable guarantees:
- A callback registered with
.then()
will only be called after the promise settles. - It will be called at most once.
- It will be called with the correct fulfillment value or rejection reason.
- Errors within the promise machinery or the initiating async operation are reliably channeled into rejections.
- A callback registered with
Promises provide a trustable, composable system for managing asynchronous operations. You get a capability (the promise) to observe the outcome, rather than handing over a capability (your callback) to potentially untrusted code.
Promise Creation and Utilities
While you mostly consume promises returned by browser APIs (fetch
) or libraries, you can create them yourself using the new Promise()
constructor, often useful for wrapping older callback-style APIs.
function delay(ms) {
return new Promise((resolve) => {
// The function passed to new Promise runs NOW (synchronously)
// resolve is a function provided by the Promise mechanism
setTimeout(() => {
resolve(`Waited ${ms}ms`); // Call resolve LATER to fulfill the promise
}, ms);
});
}
delay(1000).then((message) => console.log(message)); // Logs "Waited 1000ms" after 1 second
JavaScript also provides static helper methods like Promise.resolve(value)
(creates an already fulfilled promise), Promise.reject(reason)
(creates an already rejected promise), Promise.all(iterable)
(waits for all promises in an iterable to fulfill), and Promise.race(iterable)
(settles as soon as the first promise in an iterable settles).
Conclusion
Promises represent a significant leap forward from raw callbacks. They provide a cleaner syntax (chaining), robust error handling, and most importantly, a more reliable and trustworthy foundation for asynchronous programming by addressing the core issue of Inversion of Control. They form the bedrock upon which newer async features, like async/await, are built.
Chapter 4: Generators - Pausable Functions
After establishing Promises as a robust way to handle future values and chain asynchronous operations, YDKJS: Async & Performance takes a seemingly sideways step into Generators. While not strictly an asynchronous feature themselves, generators provide a unique execution model that, when combined with Promises, unlocks a powerful way to write asynchronous code that looks synchronous.
What is a Generator?
A generator is a special kind of function that can pause its execution and resume later, right where it left off. Normal JavaScript functions run to completion (or until an error or return
). Generators, however, allow the caller to control the function’s execution step-by-step.
You define a generator function using the function*
syntax (note the asterisk):
// Define a generator function
function* myGenerator() {
console.log("Generator started");
yield 1; // Pause and yield the value 1
console.log("Generator resumed after 1");
yield 2; // Pause and yield the value 2
console.log("Generator resumed after 2");
return "Generator finished"; // Finish execution
}
// --- How to use it ---
// 1. Call the generator function - it DOES NOT run the code yet!
// It returns an 'iterator' object.
const iterator = myGenerator();
console.log("Generator function called, iterator created.");
// 2. Call .next() on the iterator to start/resume execution
console.log("Calling next() the first time:");
let result1 = iterator.next(); // Runs code until the first 'yield'
console.log(result1); // { value: 1, done: false }
console.log("Calling next() the second time:");
let result2 = iterator.next(); // Runs code from after first 'yield' to the second 'yield'
console.log(result2); // { value: 2, done: false }
console.log("Calling next() the third time:");
let result3 = iterator.next(); // Runs code from after second 'yield' until 'return' or end
console.log(result3); // { value: 'Generator finished', done: true }
console.log("Calling next() again (generator is done):");
let result4 = iterator.next(); // Generator is already finished
console.log(result4); // { value: undefined, done: true }
Key Observations:
- Calling
myGenerator()
creates aniterator
object but doesn’t execute the function body. - The
yield
keyword pauses the generator and sends a value back to the caller via the object returned by.next()
. - The
.next()
method resumes the generator’s execution. - The object returned by
.next()
has avalue
(what was yielded or returned) and adone
flag (indicating if the generator has completed).
Two-Way Communication: yield
and .next(value)
Generators aren’t just about pausing and yielding values out; they can also receive values back from the caller. The value passed to the .next(value)
call becomes the result of the yield
expression that previously paused the generator.
function* generatorWithInput() {
console.log("Generator started");
const input1 = yield "Need first input"; // Pause, yield string
console.log("Received first input:", input1);
const input2 = yield "Need second input"; // Pause, yield string
console.log("Received second input:", input2);
return `Processed: ${input1}, ${input2}`;
}
const iterator = generatorWithInput();
// Start the generator. No value passed to first next().
console.log(iterator.next()); // { value: 'Need first input', done: false }
// Resume generator, passing 'Value A' back in.
// 'Value A' becomes the result of the first 'yield'.
console.log(iterator.next("Value A")); // Logs "Received first input: Value A"
// // Returns { value: 'Need second input', done: false }
// Resume generator, passing 'Value B' back in.
// 'Value B' becomes the result of the second 'yield'.
console.log(iterator.next("Value B")); // Logs "Received second input: Value B"
// // Returns { value: 'Processed: Value A, Value B', done: true }
This two-way communication is crucial for the async pattern.
Generators + Promises: The Async Breakthrough
Here’s where it all connects, as highlighted in YDKJS. Imagine if a generator could yield
a Promise:
- The generator encounters
yield myPromise;
. It pauses. - The caller (a special runner function) receives the yielded Promise.
- The runner attaches a
.then()
handler tomyPromise
. - When
myPromise
fulfills, the runner callsiterator.next(promiseResult)
, passing the result back into the generator. - The generator resumes, with
promiseResult
now available as the result of theyield
expression, and continues until the nextyield
,return
, or error.
If the promise rejects, the runner can use the iterator’s .throw(error)
method to inject the error back into the generator, allowing standard try...catch
blocks inside the generator to handle asynchronous errors!
// --- Conceptual Example (Requires a Runner Function) ---
// Assume 'asyncRunner' is a utility that handles yielded promises
asyncRunner(function* main() {
try {
console.log("Fetching user data...");
// yield pauses the generator, asyncRunner waits for the promise
let userData = yield ajax("/api/user"); // Looks synchronous!
console.log("Got user data:", userData);
console.log("Fetching permissions...");
// yield pauses again, asyncRunner waits for the next promise
let permsData = yield ajax("/api/permissions?id=" + userData.id);
console.log("Got permissions:", permsData);
console.log("Fetching posts...");
let postsData = yield ajax("/api/posts?perms=" + permsData.key);
console.log("Got posts:", postsData);
console.log("All done!");
} catch (error) {
// Catches errors from *any* of the yielded promises if they reject!
console.error("An error occurred:", error);
}
});
console.log("Generator-based async flow initiated: NOW");
// --- End Conceptual Example ---
Look closely at the code inside function* main()
. It reads like synchronous code! The yield
keyword acts as a pause point, waiting for the asynchronous operation (represented by the yielded promise) to complete before resuming. The complexity of .then()
chaining or callback nesting is hidden away inside the asyncRunner
utility.
The Runner is Key
YDKJS emphasizes that generators alone don’t solve the async problem. You need that runner function (like the conceptual asyncRunner
above, or libraries like co
that were popular before async/await
) to manage the interaction between the generator’s iterator and the yielded promises. This runner handles calling .next()
, attaching .then()
/.catch()
to promises, and calling .next(result)
or .throw(error)
back into the generator.
Conclusion
Generators introduced a revolutionary function execution model: pausable functions with two-way communication (yield
/.next(value)
). When combined with Promises and orchestrated by a runner function, they allowed developers to write asynchronous code that looked and behaved much more like familiar synchronous code, complete with standard try...catch
error handling. This pattern directly paved the way for the native async/await
syntax introduced later in ECMAScript.
Chapter 5 & 6: Program Performance - Benchmarking
Having explored callbacks, Promises, and generators (leading towards async/await
), YDKJS: Async & Performance shifts focus in Chapter 5 to the broader topic of program performance. While asynchronous patterns help prevent blocking the main thread (crucial for UI responsiveness), performance optimization involves more than just using Promises or async/await
. It requires understanding how JavaScript executes and where potential bottlenecks lie.
Why Performance Matters (Especially with Async)
Asynchronous operations are great for I/O tasks (network requests, file system access) because they allow the main JavaScript thread to continue working while waiting for the operation to complete. This keeps web pages responsive and servers handling multiple requests.
However, if the code that runs after an async operation completes (the callback, the .then()
handler, the code after await
) is itself computationally expensive and runs on the main thread, it can still block rendering and user interaction, negating some of the benefits of async. Performance analysis helps identify and mitigate these issues.
Measuring Performance: Benchmarking and Profiling
The first rule of optimization is: Measure! Guessing about performance bottlenecks is often wrong. YDKJS emphasizes the need for proper measurement techniques:
- Benchmarking: Comparing the relative speed of different code approaches for specific, isolated tasks. Tools like
benchmark.js
or simpleconsole.time()
/console.timeEnd()
can help, but require careful setup to ensure fair comparisons (e.g., warm-up runs, sufficient iterations). Be wary of micro-benchmarks that don’t reflect real-world usage. - Profiling: Analyzing the execution time and resource consumption (CPU, memory) of your entire application or significant parts of it. Browser developer tools (like the Performance tab in Chrome DevTools) are invaluable here. They provide flame graphs, call trees, and event timelines to pinpoint exactly where time is being spent.
Keeping the Main Thread Free: Web Workers
What if you have a genuinely CPU-intensive task (like complex calculations, data processing, cryptography) that cannot be broken down easily into small async chunks? Running this on the main thread will inevitably lead to freezing.
The solution provided by the JavaScript environment is Web Workers.
- True Parallelism: Workers run in separate threads, completely independent of the main UI thread. This allows CPU-bound tasks to execute in parallel without blocking rendering or user interaction.
- Communication: Since workers run in a different context, they don’t share memory directly with the main thread (mostly*). Communication happens via message passing using
postMessage()
and listening formessage
events. Data sent between threads is typically copied (serialized/deserialized), which has its own performance implications. - Use Cases: Ideal for heavy computations, background data synchronization, image processing, etc. Not suitable for tasks requiring direct DOM access (which workers don’t have).
// Example of creating a worker
const worker = new Worker("worker.js");
// Example of sending a message to the worker
worker.postMessage("Hello, worker!");
(SharedArrayBuffer allows memory sharing but requires careful use of Atomics for synchronization.)
Understanding Task Queues: Macro vs. Micro
We know async operations schedule tasks for “Later” via the event loop. However, YDKJS points out there isn’t just one queue. There are primarily two relevant queues:
- Task Queue (or Macrotask Queue): This is the classic queue used by
setTimeout
,setInterval
, I/O operations, UI events (clicks, etc.). The event loop picks one task from this queue per “tick” (cycle). - Job Queue (or Microtask Queue): This queue has higher priority. It’s used by Promises (
.then()
,.catch()
,.finally()
callbacks) andasync function
continuations (the code after anawait
). After the currently executing script finishes, and after each task from the Macrotask queue completes, the event loop processes all jobs currently in the Microtask queue until it’s empty. Only then does it proceed to rendering updates or the next macrotask.
Why does this matter for performance?
- Microtasks (like Promise handlers) run sooner than Macrotasks (
setTimeout(..., 0)
). - A long chain of Promise resolutions or many queued microtasks can delay the next rendering update or the handling of the next macrotask (like a user click), even though they don’t block the initial synchronous code execution as severely as long-running synchronous code.
Code-Level Optimizations & Tail Calls
The chapter might also touch upon general coding practices for performance:
- Avoiding unnecessary work within loops or frequently called functions.
- Choosing efficient algorithms and data structures.
- Understanding the cost of certain operations (e.g., DOM manipulation is expensive).
It also often discusses Tail Call Optimization (TCO). TCO is a potential optimization where if a function’s very last action is to call another function (a “tail call”), the engine can reuse the current function’s stack frame instead of creating a new one. This is particularly relevant for recursion, preventing stack overflow errors for deeply recursive patterns. However, YDKJS usually notes that while part of the ECMAScript spec, TCO support across JavaScript engines has been inconsistent and shouldn’t be relied upon universally for critical logic.
Conclusion
Performance optimization in JavaScript, especially within an asynchronous context, requires a deep understanding of the event loop, task queues (macro and micro), and the tools available for parallelism (Web Workers). It’s about keeping the main thread free for rendering and interaction, offloading heavy work, and making informed decisions based on careful measurement and profiling, rather than premature or misguided optimization efforts.
Bonus Chapter: The Event Loop - The Heartbeat of Asynchrony
While we’ve talked a lot about scheduling code for “Later” (using callbacks, Promises, etc.), we haven’t fully detailed the mechanism that actually runs that code. That mechanism is the Event Loop, a fundamental concept often mentioned alongside YDKJS, even if not a dedicated chapter in the original Async book. Understanding it is crucial for truly grasping how JavaScript handles concurrency despite being single-threaded.
What is it?
Imagine the JavaScript engine has a few key parts:
- Call Stack: Where function calls are executed. When you call a function, it’s pushed onto the stack; when it returns, it’s popped off. JavaScript executes one thing at a time from the top of the stack. This is the “Now” part.
- Web APIs / Node.js APIs: These are features provided by the environment (browser or Node.js), not the JS engine itself. Things like
setTimeout
, DOM events,fetch
, file system (fs
) calls live here. When you call one of these, the environment handles the waiting part off the main thread. - Task Queue (Macrotask Queue): When an asynchronous operation managed by the environment APIs (like
setTimeout
’s timer finishing orfetch
getting a response) is complete, its associated callback function isn’t executed immediately. Instead, it’s placed onto the Task Queue. - Microtask Queue (Job Queue): As we saw with Promises, this queue has higher priority. Callbacks registered with
.then()
,.catch()
,.finally()
, and the code following anawait
in anasync
function go here.
The Loop Cycle:
The Event Loop constantly cycles through these parts:
- It checks if the Call Stack is empty. If it’s not, it keeps processing whatever is on the stack (“Now”).
- Once the Call Stack is empty, it checks the Microtask Queue. If there are any tasks (jobs) waiting, it executes all of them, one by one, until the Microtask Queue is empty. If processing a microtask adds more microtasks, those are also processed in the same cycle.
- Only when the Microtask Queue is empty does it check the Task Queue (Macrotask Queue).
- If the Task Queue has a task, it takes the oldest one, pushes its callback function onto the (now empty) Call Stack, and executes it (“Later”).
- (In browsers) After processing microtasks and potentially one macrotask, the loop may perform rendering updates if needed.
- The loop then repeats from step 1.
Why It Matters:
This loop is how JavaScript achieves non-blocking behavior. Long-running operations (like waiting for a network request via fetch
) are handled by the environment APIs, freeing the Call Stack. When the operation completes, its handler waits patiently in the appropriate queue until the Event Loop picks it up after the current synchronous code (“Now”) and any pending microtasks have finished. This keeps the application responsive. Understanding the difference between microtasks (Promises, await
) and macrotasks (setTimeout
) helps predict the exact order of execution for complex asynchronous flows.
Bonus Chapter: async
/await
- Synchronous-Looking Async
We saw how Generators, combined with Promises and a runner function, could make asynchronous code look synchronous. The ECMAScript committee recognized the power and clarity of this pattern and baked it directly into the language with the async
and await
keywords. This is the modern standard for handling Promises in JavaScript.
async
Functions:
- The
async
keyword is placed before a function declaration (async function myFunc() { ... }
) or expression (const myFunc = async () => { ... };
). - It does two main things:
- It automatically makes the function return a Promise. If the function explicitly returns a value (e.g.,
return 42;
), theasync
function wraps that value in a fulfilled Promise (Promise.resolve(42)
). - If the function
throw
s an error, the returned Promise becomes rejected with that error. - It enables the use of the
await
keyword inside that function.
- It automatically makes the function return a Promise. If the function explicitly returns a value (e.g.,
await
Operator:
- The
await
keyword can only be used inside anasync
function. - It is placed before an expression that evaluates to a Promise (e.g.,
await fetch(...)
,await somePromise
). - It pauses the execution of the
async
function at that point. It does not block the entire JavaScript engine or the Event Loop. - It “waits” for the awaited Promise to settle (either fulfill or reject).
- If the Promise fulfills,
await
unwraps the value, and that value becomes the result of theawait
expression. Execution of theasync
function resumes. - If the Promise rejects,
await
throws the rejection reason as an error inside theasync
function.
The Beauty of It:
Let’s rewrite our Promise chaining example using async/await
:
// --- Async/Await Example ---
async function fetchUserDataFlow() {
console.log("Flow started: NOW (inside async function)"); // Still runs synchronously initially
try {
console.log("Requesting user data..."); // NOW
// Pause function execution here, wait for ajax promise
const userData = await ajax("/api/user"); // LATER (when promise resolves)
console.log("Got user data:", userData); // LATER
console.log("Requesting permissions..."); // LATER
// Pause again, wait for next promise
const permsData = await ajax("/api/permissions?id=" + userData.id); // EVEN LATER
console.log("Got permissions:", permsData); // EVEN LATER
console.log("Requesting posts..."); // EVEN LATER
// Pause again...
const postsData = await ajax("/api/posts?perms=" + permsData.key); // YET EVEN LATER
console.log("Got posts:", postsData); // YET EVEN LATER
console.log("All done!"); // YET EVEN LATER
return postsData; // Fulfills the promise returned by fetchUserDataFlow
} catch (error) {
// Catches rejections from *any* awaited promise
console.error("An error occurred in the flow:", error);
// Rejects the promise returned by fetchUserDataFlow
throw error;
}
}
// Call the async function
fetchUserDataFlow()
.then((finalData) => {
console.log("Async function completed successfully.");
})
.catch((err) => {
console.log("Async function failed.");
});
console.log("Async function called: NOW"); // Runs NOW, before any 'await' completes
// --- End Async/Await Example ---
The benefits are clear:
- Readability: The code reads almost exactly like synchronous code, making complex asynchronous flows much easier to follow.
- Error Handling: Standard
try...catch
blocks work seamlessly for handling errors from awaited Promises. - Debugging: Stepping through
async/await
code in debuggers is often more intuitive than debugging Promise chains.
async/await
is essentially syntactic sugar built on top of Promises and the generator execution model. It doesn’t introduce fundamentally new capabilities but provides a vastly improved developer experience for managing asynchronous operations.
Conclusion: Mastering the Flow of Time in JavaScript
Reviewing “YDKJS: Async & Performance” takes us on a crucial journey through the evolution of handling asynchronous operations in JavaScript. We start with the fundamental concept of code running “Now” versus being scheduled for “Later”, initially managed by Callbacks. While functional, callbacks introduce challenges like “Callback Hell” and the more subtle but significant problem of “Inversion of Control.”
Promises arrive as a robust solution, offering a trustable placeholder for future
values, enabling cleaner chaining with .then()
, and providing better error handling
via .catch()
. They restore control to the calling code.
Generators, though seemingly unrelated at first, provide the key mechanism of pausable functions (function*
and yield
), which, when combined with Promises and a runner, allow asynchronous code to be written in a more sequential, synchronous-looking style.
This pattern culminates in the native async
/await
syntax, offering the readability benefits of the generator approach directly within the language, built firmly upon the foundation of Promises.
Underpinning all of this is the Event Loop, the engine’s heartbeat, diligently managing the Call Stack and the Task/Microtask Queues to ensure non-blocking behavior and execute our “Later” code predictably. Finally, understanding Performance implications, including the use of Web Workers for CPU-intensive tasks and the nuances of task queues, allows us to build not just functional but also efficient and responsive applications.
Kyle Simpson’s exploration in YDKJS provides the deep understanding necessary to move beyond simply using these features to truly mastering the flow of time and execution in JavaScript. It’s an essential read for any serious JavaScript developer looking to write robust, maintainable, and performant asynchronous code.
#JavaScript #Async #Performance