JavaScript executes code asynchronously by default, meaning it does not wait for one function to finish before moving on to another part of the code. This allows the language to perform non-blocking I/O operations and handle high volumes of concurrent requests efficiently. However, asynchronous execution can also introduce complexity when coordinating the order of execution across different parts of your code.
There are several techniques in JavaScript to pause or "wait" for functions and asynchronous actions to complete before continuing execution. Mastering these methods is key to avoiding race conditions, errors, and inconsistent behavior from the uncontrolled concurrent execution.
This comprehensive guide explores the ins and outs of waiting for functions in JavaScript.
The Event Loop and Asynchronous JavaScript
To understand why we need to handle waiting for functions explicitly, it helps to first explain the JavaScript event loop and how it enables asynchronous capabilities.
The JavaScript engine contains a continuously running event loop which monitors a queue of callback functions and executes them one by one when events occur. For example I/O operations like network requests or timers do not block the main execution thread – instead they register callback functions which get pushed to the queue and invoked asynchronously by the event loop.
This allows JavaScript to perform non-blocking asynchronous actions without stopping main application processing. However it also means code executes unpredictably out of the written order.
So while the event loop is essential for JavaScript‘s performance and scalability, it also introduces complexity around controlling execution order across different asynchronous parts of code. Various methods exist to approach this problem and pause execution until necessary async actions complete.
Callback Functions
The callback function is the most common of the asynchronous programming patterns in JavaScript. Callbacks allow you to designate code that should run after a particular async operation finishes.
For example, you may perform a network request like this:
function request(url, callback) {
// async request logic
callback(response);
}
request(‘https://example.com‘, function(response) {
// runs after request completes
});
We pass a callback function into request to execute after the asynchronous operation finishes and the response is ready.
Here is another example using timers:
function waitOneSecond(callback) {
setTimeout(callback, 1000);
}
waitOneSecond(function() {
// runs after 1 second
});
And here is an example using promises which we will cover more later:
function asyncOperation() {
return new Promise((resolve) => {
// async operation
resolve();
});
}
asyncOperation().then(() => {
// runs after promise resolves
});
In all cases, we declare function code that should run asynchronously, while designating a callback to trigger once that code finishes at some future point in time.
This pattern works well for simple cases but tends to get messy as applications grow in complexity. Callback functions lead to pyramid code with poor legibility, known as "callback hell".
Limitations of Callbacks
While callbacks do provide a path for coordinating async actions, some downsides include:
Pyramid code: Nested callbacks indent to the right causing a pyramid shape. This makes code difficult to follow.
Inversion of control: The callback function dictates control instead of the main program flow. This leads to confusion around execution order.
Single use: Callbacks can only be used once. Any additional calls require declaring a new callback.
Error handling: Dealing with errors inside nested callbacks is challenging.
Modern patterns like promises and async/await aim to address these pain points.
Promises for Asynchronous Actions
Promises provide an improved approach over callbacks for dealing with asynchronous code. A promise represents an ongoing async task that may or may not complete at some future point.
We define promises using the Promise constructor like this:
const promise = new Promise((resolve, reject) => {
// async task
if (success) {
resolve();
} else {
reject();
}
});
The promise constructor takes an executor function which contains the asynchronous logic. The resolve and reject functions handle signaling the success or failure of the async task.
Other code can then consume the promise using .then() and .catch() methods:
promise.then(() => {
// runs on resolve
}).catch(() => {
// runs on reject
});
This separates the asynchronous action from the code waiting on its result, avoiding callback nesting.
Here is how we could implement a timeout promise:
function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
wait(1000).then(() => {
console.log(‘Done!‘);
});
And here is an HTTP request using promises:
function request(url) {
return new Promise((resolve, reject) => {
// make HTTP request
if (response.status === 200) {
resolve(response);
} else {
reject(new Error());
}
});
}
request(‘https://example.com‘)
.then(response => {
// handle response
})
.catch(error => {
// handle error
});
Promises allow asynchronous actions to be chained together, avoiding deeply nested callbacks. They also provide built-in error propagation mechanisms through rejections.
However, promises can still involve somewhat complex syntax and do not fully solve the inversion of control problem � the program flow is still dictated by handlers instead of the main sync code.
Async/Await for Synchronous Style
Async/await provides a cleaner syntax on top of promises for working with asynchronous code. Async functions allow you to write asynchronous code in a synchronous programming style using the async and await keywords.
We define async functions like regular functions but with the async keyword:
async function myAsyncFunction() {
// function body
}
Inside async functions we can pause execution and wait for promise resolutions using the await keyword:
async function waitForResponse() {
const response = await fetch(‘/api/users‘);
// execution stops until fetch promise resolves
console.log(response);
}
- The
awaitkeyword only works insideasyncfunctions awaitpauses execution until a promise settles, then returns the resolved value- Any async function returns a promise implicitly
Here is how async/await handles errors:
async function makeRequest() {
try {
const response = await fetch(‘/api/users‘);
// ...
} catch (error) {
// handle error
}
}
Async/await allows you to write asynchronous, non-blocking code as if it were synchronous without managing callbacks or chains of .then() calls. This makes code easier to read and maintain for human developers.
Let‘s look at another example with timers:
async function start() {
console.log(‘Start‘);
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(‘End‘);
}
start();
By handling the asynchronous timeout in an async function and awaiting the returned promise, we can pause execution after logging "start" until 2 seconds elapse, then execute "end".
Async functions implicitly return promises themselves, making them composable:
async function asyncTask1() {
return 1;
}
async function asyncTask2() {
return 2;
}
async function orchestrator() {
const v1 = await asyncTask1();
const v2 = await asyncTask2();
return v1 + v2;
}
orchestrator().then(v => {
console.log(v); // 3
});
Debugging Async Code
Debugging async code comes with unique challenges given callbacks, promises, async functions and the event loop can make execution order less than obvious.
Techniques for debugging async JavaScript code include:
- Console logging liberally in your code to visually follow program flow
- Using developer tools breakpoints to pause execution and inspect call stacks
- Async tracking tools like
async_hooksto monitor runtime async ordering - Promise inspection via
.finally()handlers to log promise states - Enabling async stack traces in Node.js
Overall, async/await results in code that is easier to trace logically avoiding tangled callback nesting and long promise chains.
Performance Considerations
Asynchronous techniques have performance tradeoffs to consider:
- Throughput: Async code has higher overall throughput since I/O does not block threads.
- Latency: Individual operations can have higher latency due to callbacks and function wrappers.
- Memory: Heavy async code uses more memory tracking state across different contexts.
Benchmarking tools like jsPerf can measure performance differences between asynchronous patterns with callbacks, promises, async functions, and sync code.
In general async/await have little overhead over promises, while both async techniques are designed to improve throughput over synchronous code via non-blocking I/O handling under the hood.
Browser Support and Transpilation
Async functions and the await keyword were standardized in ES2017. Most modern browsers now support native async/await, however legacy browser support requires transpilation.
Tools like Babel can transform modern JavaScript with async/await syntax down to an older standard like ES5 by rewriting async functions. This allows you to use the latest language features while targeting outdated browser environments.
For Node.js, version 7.6+ supports async functions without flags. Prior versions require the --harmony-async-await flag enabled.
Real World Use Cases
There are many practical use cases where waiting for asynchronous function execution is necessary in application code:
- Allowing UI to fully load before fetching data or enabling functionality
- Waiting on module initialization before rendering UI components
- Sequential data loading from multiple external APIs or databases
- Authentication gates where anonymous vs authorized states dictate app behavior
- Ensuring consistent UI component state updates from asynchronous data
- Atomic transaction processing to avoid race conditions between parallel operations
Proper async coordination is essential for consistent behavior as application complexity increases.
Alternative Patterns
In addition to callbacks, promises and async/await, here are a few other patterns for coordinating async behavior:
- Async modules: Load async dependencies like SDKs using module imports with fallback options.
- Web workers: Offload complex async processing to dedicated background worker threads communicating via message passing.
- Observables: Use RxJS/Observable streams for reactive async state monitoring.
- Async iterators: Handle async iteration over streams directly via for await…of loops.
Each approach has different strengths based on the architecture and use case requirements.
Waiting for Events
Another common async paradigm in JavaScript is waiting for events, like user input or sensor data. Callbacks are often used internally for registering transient event handlers:
// event handler callback
document.addEventListener(‘click‘, function() {
// handle click event
});
However promises and async/await generally do not make sense for discrete ephemeral events.
Alternatives like RxJS Observables allow you to declaratively compose sequences of async events using operators like .pipe(), .filter(), .map() over observable streams instead of imperative callbacks alone.
Error Handling
With async code, errors have additional complexity with multiple callback layers catching and propagating errors.
Common problems include:
-
Swallowed errors: Unhandled rejected promises or throw statements within callback functions and promises. These situations can lead to silent failures.
-
Mixed approaches: Some libraries return promises while others accept error-first callbacks. Bridging between different patterns introduces further complexity.
Strategies for managing errors in async code:
- Use
.catch()handlers when consuming promises - Wrap callback-based APIs in tiny promise wrappers
- Use
try...catchblocks in async functions - Centralize error logging and monitoring
- Graceful feature degradation if failures occur
Proper error handling ensures failures surface for diagnosis instead of causing silent defects.
Async vs Synchronous Languages
JavaScript provides unique flexibility for asynchronous, non-blocking behavior. This contrasts with languages like C# or Java which have traditional synchronous execution models by default.
In C#, asynchronous logic requires more ceremony – methods must be explicitly declared with the async modifier and return Task objects. The await keyword then allows waiting on tasks similar to JavaScript promises.
Java has a comparable Future pattern for encapsulating asynchronous logic. Async completable future objects allow chaining actions with methods like .thenCompose() thenAccept() among others.
So while achievable, async coding requires more developer overhead in these languages compared to JavaScript‘s lightweight callback and promise implementations.
The Future of Async JavaScript
Async JavaScript continues to evolve with new capabilities arriving regularly:
Top Level Await
Future versions of Node.js are expected to support the await keyword outside async functions, eliminating boilerplate async wrappers for simple cases.
Observables
Native observable support could allow async iteration via for await (let x of xs) {} loops without needing external libraries.
Cancelable Promises
A proposed AbortController API would allow promises to be canceled when no longer necessary through an abort signal.
Async Context Propagation
Advanced experimental APIs provide ways to track context across asynchronous calls to aid diagnostics and tracing.
In summary, the async coding experience in JavaScript keeps getting richer, allowing complex non-blocking applications to be built with a simpler and more intuitive coding style over time.
Frequently Asked Questions
Here are answers to some common questions about waiting for functions in JavaScript:
What is the difference between callbacks and promises?
Callbacks provide basic async coordination while promises represent the ongoing result of an async action. Callbacks lead to inversion of control and callback hell while promises have better composability, chaining and error handling due to their first-class status in the language.
Are async/await just syntactic sugar over promises?
At a fundamental level yes, but async functions provide a substantially cleaner syntax and coding experience. Async/await allows logic to be written sequentially avoiding pyramid code while still leveraging asynchronous behavior under the hood via promises.
When should I use callbacks vs promises vs async/await?
- Callbacks: Simple cases or legacy code still reliant on error-first callback style.
- Promises: Consuming promise-based APIs or chaining async actions.
- Async/await: Any new async logic, especially when performance is not the primary factor.
What Node.js versions support async/await?
Async functions are supported natively without flags in Node.js 7.6+. Prior versions require the --harmony-async-await flag enabled to turn on the unsupported feature.
Can async/await be used with event handler callbacks?
Generally no, async/await relies on promises under the hood. Callbacks are better suited for transient events like click handlers while async/await is useful for discrete asynchronous tasks.
Key Takeaways
- JavaScript leverages an event loop for asynchronous non-blocking behavior.
- Callbacks enable basic async coordination but suffer issues like pyramid code.
- Promises represent the result of async work and avoid callback hell via chaining.
- Async/await provides sequential code style using promises and async functions.
- Pausing execution with async techniques prevents race conditions between asynchronous logic.
By mastering callbacks, promises and async/await, you can gracefully handle asynchronous actions in JavaScript. Async powerfully enables non-blocking programs but requires coordination patterns to sequence executions cleanly.


