Synchronous and asynchronous functions
Synchronous functions are blocking while asynchronous functions are not. In synchronous functions, statements complete before the next statement is run. As a result, programs containing only synchronous code are evaluated exactly in order of the statements. The execution of the program is paused if one of the statements take a very long time.
Asynchronous functions usually accept a callback as a parameter and execution continue on to the next line immediately after the asynchronous function is invoked. The callback is only invoked when the asynchronous operation is complete and the call stack is empty. Heavy duty operations such as loading data from a web server or querying a database should be done asynchronously so that the main thread can continue executing other operations instead of blocking until that long operation to complete (in the case of browsers, the UI will freeze).
Callbacks
A callback function is a function that is passed as an argument to another function and is executed after some operation has been completed. This is particularly useful in asynchronous programming, where operations like network requests, file I/O, or timers need to be handled without blocking the main execution thread.
- Synchronous callbacks are executed immediately within the function they are passed to. They are blocking and the code execution waits for them to complete.
Example
function greet(name, callback) {
console.log("Hello " + name);
callback();
}
function sayGoodbye() {
console.log("Goodbye!");
}
greet("Alice", sayGoodbye);
// Output:
// Hello Alice
// Goodbye!- Asynchronous callbacks are executed after a certain event or operation has been completed. They are non-blocking and allow the code execution to continue while waiting for the operation to finish.
Example
function fetchData(callback) {
setTimeout(() => {
const data = { name: "John", age: 30 };
callback(data);
}, 1000);
}
fetchData((data) => {
console.log(data);
});
// Output after 1 second:
// { name: 'John', age: 30 }Common use cases:
- Network requests: Fetching data from an API
- File I/O: Reading or writing files
- Timers: Delaying execution using
setTimeoutorsetInterval - Event handling: Responding to user actions like clicks or key presses
When dealing with asynchronous operations, it’s important to handle errors properly. A common pattern is to use the first argument of the callback function to pass an error object, if any.
Example
function fetchData(callback) {
setTimeout(() => {
const error = null;
const data = { name: "John", age: 30 };
callback(error, data);
}, 1000);
}
fetchData((error, data) => {
if (error) {
console.error("An error occurred:", error);
} else {
console.log(data);
}
});Promises
Promises in JavaScript are objects that represent the eventual completion (or
failure) of an asynchronous operation and its resulting value. They have three
states: pending, fulfilled, and rejected. You can handle the results of a
promise using the .then() method for success and the .catch() method for
errors. The finally block is used to execute code after a try and catch
block, regardless of whether an error was thrown or caught. It ensures that
certain cleanup or finalization code runs no matter what. They provide a
cleaner, more readable way to handle asynchronous code compared to traditional
callback functions.
Creating a promise
You create a promise using the Promise constructor, which takes a function
with two arguments: resolve and reject. These are callbacks that you call to
change the state of the promise.
Immediate Execution: The function you pass to the Promise constructor is
executed immediately when the Promise is created. It’s generally used for
wrapping an asynchronous operation directly.
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = true; // or false
if (success) {
resolve("Operation successful");
} else {
reject("Operation failed");
}
}, 1000);
});
myPromise
.then((result) => console.log(result))
.catch((error) => console.error(error));Or create a function that returns a promise.
Deferred Execution: The function fetchData does not execute the Promise
until it is called. This allows you to create Promises based on different
conditions or contexts.
const fetchData = () => {
return new Promise((resolve, reject) => {
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => resolve(data))
.catch((error) => reject(error));
});
};
fetchData()
.then((data) => console.log(data))
.catch((error) => console.error(error));Chaining promises
Promises can be chained to handle multiple asynchronous operations in sequence.
Each .then() returns a new promise, allowing for further chaining.
promise
.then((result) => {
console.log(result);
return anotherPromise;
})
.then((anotherResult) => {
console.log(anotherResult);
})
.catch((error) => {
console.error(error);
});Combining promises
You can use Promise.all() to run multiple promises in parallel and wait for
all of them to complete.
let promise1 = Promise.resolve("First");
let promise2 = Promise.resolve("Second");
Promise.all([promise1, promise2]).then((results) => {
console.log(results); // ['First', 'Second']
});Callbacks vs promises
Avoid callback hell which can be unreadable
Callback hell, also known as the “pyramid of doom,” is a phenomenon that occurs when you have multiple nested callbacks in your code. This can lead to code that is difficult to read, maintain, and debug. Here’s an example of callback hell:
Example
function getFirstData(callback) {
setTimeout(() => {
callback({ id: 1, title: "First Data" });
}, 2000);
}
function getSecondData(data, callback) {
setTimeout(() => {
callback({ id: data.id, title: data.title + " Second Data" });
}, 2000);
}
function getThirdData(data, callback) {
setTimeout(() => {
callback({ id: data.id, title: data.title + " Third Data" });
}, 2000);
}
// Callback hell
getFirstData((data) => {
getSecondData(data, (data) => {
getThirdData(data, (result) => {
console.log(result); // Output: {id: 1, title: "First Data Second Data Third Data"}
});
});
});Promises address the problem of callback hell by providing a more linear and readable structure for your code.
Example
// Example of sequential asynchronous code using setTimeout and Promises
function getFirstData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 1, title: "First Data" });
}, 2000);
});
}
function getSecondData(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: data.id, title: data.title + " Second Data" });
}, 2000);
});
}
function getThirdData(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: data.id, title: data.title + " Third Data" });
}, 2000);
});
}
getFirstData()
.then(getSecondData)
.then(getThirdData)
.then((data) => {
console.log(data); // Output: {id: 1, title: "First Data Second Data Third Data"}
})
.catch((error) => console.error("Error:", error));Makes it easy to write sequential asynchronous code that is readable with
.then(): In the above code example, we use .then() method to chain these
Promises together, allowing the code to execute sequentially. It provides a
cleaner and more manageable way to handle asynchronous operations in JavaScript.
Makes it easy to write parallel asynchronous code with Promise.all(): Both
Promise.all() and callbacks can be used to write parallel asynchronous code.
However, Promise.all() provides a more concise and readable way to handle
multiple Promises, especially when dealing with complex asynchronous workflows.
Example
function getData1() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 1, title: "Data 1" });
}, 2000);
});
}
function getData2() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 2, title: "Data 2" });
}, 2000);
});
}
function getData3() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ id: 3, title: "Data 3" });
}, 2000);
});
}
Promise.all([getData1(), getData2(), getData3()])
.then((results) => {
console.log(results); // Output: [[{ id: 1, title: 'Data 1' }, { id: 2, title: 'Data 2' }, { id: 3, title: 'Data 3' }]
})
.catch((error) => {
console.error("Error:", error);
});With promises, these scenarios which are present in callbacks-only coding, will not happen:
- Call the callback too early
- Call the callback too late (or never)
- Call the callback too few or too many times
- Fail to pass along any necessary environment/parameters
- Swallow any errors/exceptions that may happen
Async/Await
Syntax
async/await is a feature introduced in ECMAScript 2017 (ES8) that allows you
to write asynchronous code in a more synchronous-looking manner. It is built on
top of promises and provides a cleaner and more readable way to handle
asynchronous operations. By using the async keyword before a function, you can
use the await keyword inside that function to pause execution until a promise
is resolved. This makes asynchronous code look and behave more like synchronous
code, making it easier to read and maintain.
Example
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Error fetching data:", error);
}
}The async keyword is used to declare an asynchronous function. When a function
is declared as async, it automatically returns a promise. This means you can
use the await keyword inside it to pause the execution of the function until a
promise is resolved.
Example
async function exampleFunction() {
return "Hello, World!";
}
exampleFunction().then(console.log); // Output: Hello, World!The await keyword can only be used inside an async function. It pauses the
execution of the function until the promise is resolved, and then returns the
resolved value. If the promise is rejected, it throws an error, which can be
caught using a try...catch block.
Example
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Error fetching data:", error);
}
}Simplifying asynchronous code
Before async/await, handling asynchronous operations often involved chaining
multiple .then() calls, which could lead to “callback hell” or “pyramid of
doom.” async/await flattens this structure, making the code more readable and
easier to maintain.
Example with promises
fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error("Error fetching data:", error);
});Example with async/await
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Error fetching data:", error);
}
}Error handling
Error handling with async/await is more straightforward compared to promises.
You can use try...catch blocks to handle errors, making the code cleaner and
more intuitive.
Example
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Error fetching data:", error);
}
}Workers
Workers in JavaScript are a way to run scripts in background threads, separate from the main execution thread of a web page. This allows for long-running or computationally intensive tasks to be offloaded from the main thread, preventing the user interface from becoming unresponsive or janky.
Web workers / Dedicated workers
- Run scripts in background threads separate from the main UI thread.
- Designed for CPU-intensive tasks like data processing, mathematical computations, etc. Generally the non-async work.
- Cannot directly access the DOM or other main thread resources for security.
- Communicates with main thread via asynchronous message passing –
postMessage()andonmessage/'message'. - Terminated when main script is unloaded or explicitly terminated.
Web workers can be used for:
- Image/video processing
- Data compression
- Complex math
Creating a web worker
To create a web worker, you need a separate JavaScript file that contains the code for the worker. Here’s an example:
main.js (main script)
// Check if the browser supports workers
if (window.Worker) {
// Create a new Worker
const myWorker = new Worker("worker.js");
// Post a message to the worker
myWorker.postMessage("Hello, Worker!");
// Listen for messages from the worker
myWorker.onmessage = function (event) {
console.log("Message from Worker:", event.data);
};
// Error handling
myWorker.onerror = function (error) {
console.error("Error from Worker:", error);
};
}worker.js (worker script)
// Listen for messages from the main script
onmessage = function (event) {
console.log("Message from Main Script:", event.data);
// Perform a task (e.g., some computation)
const result = event.data + " - Processed by Worker";
// Post the result back to the main script
postMessage(result);
};In this example:
main.jscreates a worker using theWorkerconstructor and specifiesworker.jsas the script to run in the worker thread.- It posts a message to the worker using
postMessage(). - The worker script (
worker.js) listens for messages from the main script usingonmessage. - After processing the message, the worker posts a message back to the main
script using
postMessage(). - The main script listens for messages from the worker using
onmessageon theWorkerinstance.
Service workers
- Act as a network proxy between web app, browser, and network.
- Can intercept and handle network requests, cache resources.
- Enable offline functionality and push notifications.
- Have a lifecycle managed by the browser (install, activate, update).
- No access to DOM and main thread resources for security.
Service workers can be used for:
- Caching
- Offline support
- Request handling
- Background sync
Creating a service worker
main.js (main script)
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/service-worker.js")
.then(function (registration) {
console.log("Service Worker registered:", registration);
})
.catch(function (err) {
console.log("Service Worker registration failed:", err);
});
}service-worker.js (service worker script)
self.addEventListener("fetch", function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
// return cached response if available
if (response) {
return response;
}
// Otherwise, fetch from network
return fetch(event.request);
})
);
});In this example:
- The main script registers a service worker at
/service-worker.js. - The service worker listens for the
fetch()event, which is fired whenever the browser makes a network request. - The service worker first checks if the requested resource is cached using
caches.match(event.request). - If it is, it returns the cached response. Otherwise, it fetches the resource
from the network using
fetch(event.request).
Shared workers
- Can be accessed from multiple scripts in different windows/tabs/iframes.
- Allow data sharing between browser contexts via a messaging interface.
- Similar to dedicated web workers but with a broader scope.
- Can be used for sharing across multiple windows.
Bonus: Worklets
The Worklet interface is a lightweight version of Web Workers and gives
developers access to low-level parts of the rendering pipeline. With Worklets,
you can run JavaScript and WebAssembly code to do graphics rendering or audio
processing where high performance is required.
You are not expected to know about worklets, so it won’t be covered in great detail. Read more about worklets on MDN.
Considerations and limitations
- Same-Origin policy: Workers must comply with the same-origin policy, meaning the script that creates the worker and the worker script itself must be from the same origin.
- No DOM access: Workers do not have direct access to the DOM. They can communicate with the main thread through messages.
- Performance: Creating and managing workers incurs overhead. They should be used judiciously for tasks that truly benefit from parallel execution.
- Error handling: Proper error handling mechanisms should be in place to handle any issues within the worker scripts.