Asynchronous JavaScript
I. The Event Loop
Resource: The JS Event Loop
1. JavaScript is Single-Threaded
JavaScript can only execute one piece of code at a time. There's only one [[06. Function Basics#II. JavaScript Call Stack|call stack]] where functions get executed.
function first() {
console.log('1');
}
function second() {
console.log('2');
}
function third() {
console.log('3');
}
first();
second();
third();
// Output: 1, 2, 3 (in order, one at a time)
2. Problem: Blocking Operations
Problem: JavaScript has to deal with time-consuming operations such as:
- Fetching data from servers/APIs
- Reading files
- Database queries
- User interactions
- Timers and delays
→ If JavaScript waited for each operation to complete before moving to the next line, web pages would freeze during any network request or file operation.
console.log('Start');
// Imagine this takes 3 seconds to complete
const data = fetchDataFromServer(); // This would FREEZE everything for 3 seconds!
console.log('End');
Solution: JavaScript uses asynchronous programming to handle time-consuming operations without blocking the main thread.
3. setTimeout()
This is a Web API Function that executes a function after a specified delay in milliseconds. The function returns a time ID, which can be used with [[07. Asynchronous JavaScript#Clearing Timeouts - clearTimeout | clearTimeout]] to cancel.
setTimeout(callback, delay, ...args)
- callback: Function to execute after the delay
- delay: Time in milliseconds to wait (minimum delay, not guaranteed exact timing)
- args: Optional parameters to pass to the callback function
setTimeout(() => {
console.log("This runs after 2 seconds (2000 milliseconds)");
}, 2000);
function sayHello(){
console.log("Hello!");
}
setTimeout(sayHello, 1000); // executes after 1 second
// passing argumements to the callback
setTimeout((name, age) => {
console.log(`Hello ${name}, you are ${age} years old`);
}, 1500, "Alice", 25);
a. Clearing Timeouts - clearTimeout
const timerId = setTimeout(() => {
console.log("This won't run");
}, 3000);
// Cancel the timeout before it executes
clearTimeout(timerId);
b. Common Mistakes
// ❌ Wrong: Calling function immediately
setTimeout(myFunction(), 1000); // Executes immediately!
// ✅ Correct: Passing function reference
setTimeout(myFunction, 1000);
// ✅ Correct: Using arrow function
setTimeout(() => myFunction(), 1000);
4. Visualizing the Event Loop
Resources
The event loop constantly checks two things:
- “Is the call stack empty?”
- “Are there callbacks waiting to run?” It only moves callbacks from the queue to the stack when the stack is completely empty.
Microtask vs. Microtask Queue
- Microtasks - highest priority:
- Promise callbacks (
.then,.catch,finally) async,awaitcontinuation
- Macrotasks - lower priority:
setTimeout,setInterval- DOM events
- I/O operations
The Rule: After EVERY single function call completes, JavaScript processes ALL microtasks before moving to the next macrotask.
console.log('1'); // synchronous, runs immdiately on the call stack
setTimeout(() => console.log('2'), 0); // Macrotask
Promise.resolve().then(() => console.log('3')); // Microtask
Promise.resolve().then(() => console.log('4')); // Microtask
console.log('5'); // // synchronous, runs immdiately on the call stack
// Output: 1, 5, 3, 4, 2
// All microtasks (3, 4) run before any macrotask (2)
AandDexecute immediately from the call stack (synchronous)Ccomes from the microtask queueBcomes from the macrotask queue
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
// Output: A, D, C, B
Aprints (synchronous)new Promise()creates promise — executor runs immediatelyBprints (sync, inside Promise executor)resolve()schedules the.then()callbackCprints (synchronous)Eprints (synchronous)D: Success!prints (from microtask queue)
console.log('A');
const promise = new Promise((resolve, reject) => {
console.log('B');
resolve('Success!');
});
console.log('C');
promise.then((result) => {
console.log('D:', result);
});
console.log('E');
// Output: A, B, C, E, D: Success!
This system allows JavaScript to appear to do multiple things at once, even though it's single-threaded. While waiting for a network request, the browser can handle user clicks, scroll events, and other JavaScript code.
II. Callbacks—Traditional Approach
Resources
A [[06. Function Basics#3. Callback Functions|callback function]] is a function passed into another function as an argument, which is then invoked inside the outer function to complete some kind of routine or action.
Example 1:// Simple callback example
function greet(name, callback) {
console.log('Hello ' + name);
callback();
}
function afterGreeting() {
console.log('Nice to meet you!');
}
greet('John', afterGreeting);
// Output:
// Hello John
// Nice to meet you!
Example 2: Event listeners—very common. The anonymous function is a callback that runs when the click event occurs.
myDiv.addEventListener("click", function(){
console.log("Button was clicked!");
});
1. Callbacks for Asynchronous Operations
// Simulating an asynchronous operation
function fetchData(callback) {
console.log("Starting to fetch data...");
// Simulate network delay with setTimeout
setTimeout(() => {
const data = { id: 1, name: "John", email: "john@example.com" };
console.log("Data fetched!");
callback(data); // Call the callback with the fetched data
}, 2000);
}
function handleData(data) {
console.log("Received data:", data);
}
fetchData(handleData);
console.log("This runs immediately, before data is fetched!");
// Output:
// Starting to fetch data...
// This runs immediately, before data is fetched!
// (2 second delay)
// Data fetched!
// Received data: {id: 1, name: "John", email: "john@example.com"}
2. Callback Hell
Even though callbacks are useful in certain situations, using callbacks can get out of hand, causing callback hell. When you need to chain multiple asynchronous operations, callbacks can become deeply nested and hard to manage.
- Hard to read and understand
- Difficult to handle errors
- Complex debugging
- Poor maintainability
fetchUser(userId, function(user) {
fetchUserPosts(user.id, function(posts) {
fetchPostComments(posts[0].id, function(comments) {
fetchCommentReplies(comments[0].id, function(replies) {
// Finally do something with the replies
console.log(replies);
});
});
});
});
III. Promises — Modern Solution
Resources
ES6 (2015) introduced native Promises and ES2017 (2017) introduced async/await. Now, modern APIs, return Promises by default, and callback hell is a largely solved problem.
→ Promises have become a standard way to handle asynchronous code in JavaScript.
A Promise is a JavaScript object that represents a future value. Think of it as a container that will eventually hold the result of an asynchronous operation, whether that result is success or failure:
- Imagine, when you order food, you get a receipt (promise) that says "your food will be ready eventually.”
- The receipt isn't the food itself, but it represents the future delivery of your meal.
Problem: We want to fetch data from a server and returns it an object that we can use in our code.
- We can write a
getDatafunction to fetch then return this data. - The issue here is, it takes time to fetch the data. When we try to access
pieceOfData, thegetData()function would likely still be running, hencemyDatawould still beundefined.
const getData = function() {
// go fetch data from some API...
// clean it up a bit and return it as an object:
return data;
}
const myData = getData();
const pieceOfData = myData['whatever'];
Solution: We can use promise to tell our code to wait until the data is done fetching to continue. In this example, we are using the .then() function.
const myData = getData() // if this is refactored to return a Promise...
myData.then(function(data){ // .then() tells it to wait until the promise is resolved
const pieceOfData = data['whatever'] // and THEN run the function inside
})
1. Creating Promises & Promise States
a. Creating a Promise
The Promise executor function runs immediately and synchronously when you create the Promise. Only the .then() callback gets scheduled for later.
The components of a promise are:
- Executor function: The function passed to
new Promise(). You will get two functions as parameters:resolve: Function to call when operation succeedsreject: Function to call when operation fails
- State: pending → fulfilled or rejected
- Value: The result data (from
resolve) or error (fromreject)
You can pass anything we want, but it’s better to pass meaningful message indicating success/failure of the promise.
const myPromise = new Promise((resolve, reject) => {
// code here
});
Example 1: This function runs immediately when Promise is created. You get two functions as parameters: resolve/reject.
const myPromise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("Operation completed successfully!");
} else {
reject("Operation failed!");
}
}); // Only one parameter - the executor function
console.log(myPromise); // Promise { <fulfilled>: "Operation completed successfully!" }
// Resolves immediately, no delay
function watchTutorialPromise() {
return new Promise((resolve, reject) => {
if(userLeft){
reject({
name: 'User Left',
message: ':('
});
} else if (userWatchingCatMeme){
reject({
name: 'User Watching Cat Meme',
message: 'Cat > Tutorial'
});
} else {
resolve('Thumbs up and Subscribe');
}
})
}
watchTutorialPromise().then(message => {
console.log('Success' + message);
}).catch(error => {
console.log(error.name + ' ' + error.message);
})
b. Promise States
A promise can be in one of three states:
- Pending: Initial state, neither fulfilled nor rejected
- Fulfilled: Operation completed successfully
- Rejected: Operation failed
Once a Promise moves from pending to either fulfilled or rejected, it can never change states again. This immutability is crucial for reliable asynchronous programming.
Example After 1 second:
promise1changes frompending→fulfilledwith value "Operation successful!"promise2changes frompending→rejectedwith reason "Operation failed!"
Key insight: Promises are created immediately, but their state changes happen when the async operation completes.
function createPromise(shouldSucceed) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldSucceed) {
resolve('Operation successful!');
} else {
reject('Operation failed!');
}
}, 1000);
});
}
const promise1 = createPromise(true);
const promise2 = createPromise(false);
2. .then() method—Accessing the Value
The .then() method tells the promise what to do when it resolves (succeeds). If the promise is rejected, this won’t run. This is how you get the actual value from a Promise.
myPromise.then(function(result) {
console.log(result); // "Operation completed successfully!"
// Here 'result' is the actual value, not a Promise
});
// Or with arrow functions
myPromise.then(result => {
console.log(result);
});
Critical concept: You CANNOT get the value out of a Promise directly. You must use .then().
// ❌ This doesn't work
const userData = fetch('/api/user'); // This is a Promise, not user data
console.log(userData.name); // Error: Promise doesn't have a 'name' property
// ✅ This is how you access the actual data
fetch('/api/user')
.then(response => response.json())
.then(userData => {
console.log(userData.name); // Now userData is the actual object
// All code that uses userData must be here or in more .then()s
});
What happens if you DON'T use .then()?
→ Without .then(), you just have a Promise object, not the actual data.
const myPromise = fetch('/api/user');
console.log(myPromise); // Promise { <pending> }
// Later, even when the fetch completes:
console.log(myPromise); // Promise { <fulfilled>: Response }
// You still can't access the Response directly:
console.log(myPromise.status); // undefined (Promise doesn't have a status property)
3. .catch() method — Error Handling
We use the catch() method for error handling. It only catches errors from ANY previous step in the chain.
fetchData()
.then(data => {
console.log("Success:", data);
})
.catch(error => {
console.error("Error:", error); // runs when the promise isn't fulfilled but rejected
});
Centralized error handling — one of Promise's biggest advantages:
fetchUserData()
.then(userData => processUserData(userData))
.then(processedData => saveToDatabase(processedData))
.then(saveResult => sendConfirmationEmail(saveResult))
.catch(error => {
// This catches errors from ANY step above
if (error.type === 'NetworkError') {
showMessage('Please check your internet connection');
} else if (error.type === 'ValidationError') {
showMessage('Please check your input data');
} else {
showMessage('Something went wrong. Please try again.');
}
});
Example:
When any .then() throws an error, the Promise chain jumps directly to the nearest .catch(), skipping any .then() handlers in between.
const promise = new Promise((resolve, reject) => {
resolve('Success!');
});
promise
.then(result => {
console.log('A:', result); // printed
throw new Error('Something went wrong!');
})
.then(result => {
console.log('B:', result); // this gets skipped
})
.catch(error => {
console.log('C:', error.message); // printed
});
Recovering from Errors
Key Rule: What you do in
.then()or.catch()determines what happens next:
- Return a value → Next
.then()runs with that value- Throw an error → Next
.catch()runs with that error- No return → Next
.then()runs withundefined
Pattern 1: Recovery (Rejection → Resolution): Continuing after recovery
Promise.reject('Original error')
.catch(error => {
return 'Fixed it'; // Recovery
})
.then(result => {
console.log('Recovered:', result); // This runs
return 'All good';
})
.then(result => {
console.log('Final:', result); // This also runs
});
Promise.reject('Original error')
.catch(error => {
console.log('Caught:', error);
throw new Error('New error'); // Creates new rejection
})
.catch(error => {
console.log('Final catch:', error.message); // Catches the new error
});
Promise.resolve('Success!')
.then(result => {
console.log('Got:', result);
throw new Error('Something went wrong'); // Success becomes rejection
})
.catch(error => {
console.log('Error:', error.message); // This runs
});
4. .finally() method — Cleanup
Runs regardless of whether the promise resolves or rejects. Used for cleanup operations.
showLoadingSpinner(); // Show loading indicator
fetchUserData()
.then(data => {
displayUserData(data);
})
.catch(error => {
showErrorMessage(error.message);
})
.finally(() => {
hideLoadingSpinner(); // Always hide loading, success or failure
});
5. Promise Chaining—Avoiding Callback Hell
Every Promise method (.then(), .catch(), .finally()) returns a new Promise → this allows chaining.
When we use promise chaining, the next chain must wait for their predecessor chain to complete before running. The next Promise's state depends on what you do inside the method.
Universal Rule for ALL Promise Methods:
- Return a value → next Promise resolves with that value
- Return a Promise → next
.then()waits for that Promise and gets its value - Throw an error → next Promise rejects with that error
- No return → next Promise resolves with
undefinedvalue
Promise.reject('Error!')
.catch(error => {
console.log('Handling:', error);
return 'Recovered value'; // .catch() resolves → next .then() runs
})
.then(result => {
console.log('Success:', result); // This runs!
return 'Continuing normally';
})
.then(result => {
console.log('Final:', result); // This also runs!
});
Promise.resolve('Success!')
.then(result => {
console.log('Got:', result);
throw new Error('Something broke'); // .then() rejects → next .catch() runs
})
.catch(error => {
console.log('Caught:', error.message);
throw new Error('Still broken'); // .catch() rejects → next .catch() runs
})
.catch(error => {
console.log('Final catch:', error.message);
return 'Finally fixed'; // .catch() resolves → next .then() runs
})
.then(result => {
console.log('Recovered:', result); // This runs!
});
Common Real-World Pattern
Here's the pattern you'll use most often:
function loadUserProfile(userId) {
return fetch(`/api/users/${userId}`)
.then(response => {
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
})
.then(userData => {
// Process the data
return {
...userData,
displayName: `${userData.firstName} ${userData.lastName}`
};
});
}
// Usage
loadUserProfile(123)
.then(user => {
console.log(`Welcome, ${user.displayName}!`);
})
.catch(error => {
console.error('Failed to load user:', error.message);
});
Key takeaway: When writing Promise code, think in terms of “what happens next” rather than trying to get values out immediately.
6. Promise.all()
Promise.all() takes an array of promises and returns a single promise that:
- Resolves when ALL input promises resolve (with an array of all results)
- Rejects immediately when ANY input promise rejects
Promise.all([promise1, promise2, promise3])
.then(results => {
// results is an array: [result1, result2, result3]
})
.catch(error => {
// Runs if ANY promise rejects
});
Example 1: If all the promises get resolved
const promise1 = Promise.resolve('First');
const promise2 = Promise.resolve('Second');
const promise3 = Promise.resolve('Third');
Promise.all([promise1, promise2, promise3])
.then(results => {
console.log(results); // ['First', 'Second', 'Third']
});
Example 2: If there’s any rejected promise, it gets caught right away.
const promise1 = Promise.resolve('Success');
const promise2 = Promise.reject('Failed!');
const promise3 = Promise.resolve('Also success');
Promise.all([promise1, promise2, promise3])
.then(results => {
console.log('This never runs');
})
.catch(error => {
console.log(error); // 'Failed!' - stops at first rejection
});
Use Case
a. Concurrent Async Operations
Promise.all lets you start multiple async operations concurrently, even though JavaScript itself processes their results one at a time.
// ❌ Sequential - WAIT for each one
const start = Date.now();
const london = await fetch('/api/weather/london'); // Wait 1000ms
const paris = await fetch('/api/weather/paris'); // Wait another 1000ms
const tokyo = await fetch('/api/weather/tokyo'); // Wait another 1000ms
console.log(`Total time: ${Date.now() - start}ms`); // ~3000ms
// ✅ Concurrent - START all, then wait for all
const start = Date.now();
const [london, paris, tokyo] = await Promise.all([
fetch('/api/weather/london'), // All start immediately
fetch('/api/weather/paris'),
fetch('/api/weather/tokyo')
]);
console.log(`Total time: ${Date.now() - start}ms`); // ~1000ms (the slowest one)
b. All-or-Nothing vs. Best Effort Pattern
- Pattern 1: All-or-Nothing (Traditional)
// Traditional approach - fails if ANY promise rejects
const promises = [promise1, promise2, promise3];
Promise.all(promises)
.then(results => {
// Only runs if ALL promises succeed
console.log('All succeeded:', results);
})
.catch(error => {
// Runs if ANY promise fails
console.log('At least one failed:', error.message);
});
// Fail-safe approach - gets results from all, even if some fail
const promises = [promise1, promise2, promise3];
Promise.all(promises.map(p => p.catch(err => ({ error: err.message }))))
.then(results => {
// Always runs - mix of successful results and error objects
results.forEach((result, index) => {
if (result.error) {
console.log(`Promise ${index} failed:`, result.error);
} else {
console.log(`Promise ${index} succeeded:`, result);
}
});
});
Example: Even when there’s an error, we should always return something so that the Promise.all won’t fail.
// Method 1: .catch()
function fetchThreeAPIs_Promise() {
const promises = [
fetch('https://api1.com/data')
.then(response => {
if (!response.ok) {
throw new Error('HTTP Error');
}
return response.json();
})
.catch(error => {
return { error: 'Failed to fetch API 1' };
}),
fetch('https://api2.com/data')
.then(response => {
if (!response.ok) {
throw new Error('HTTP Error');
}
return response.json();
})
.catch(error => {
return { error: 'Failed to fetch API 2' };
}),
fetch('https://api3.com/data')
.then(response => {
if (!response.ok) {
throw new Error('HTTP Error');
}
return response.json();
})
.catch(error => {
return { error: 'Failed to fetch API 3' };
})
];
return Promise.all(promises);
}
// Method 2: try catch block - async
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error ('HTTP Error');
}
return await response.json();
} catch (error) {
return { error: 'Failed to fetch API' };
}
}
async function fetchThreeAPIs_Async() {
const responses = await Promise.all([
safeFetch('https://api1.com/data'),
safeFetch('https://api2.com/data'),
safeFetch('https://api3.com/data')
]);
return responses;
}
7. Promise.race()
Promise.race() takes an array of promises and returns a promise that settles (resolves OR rejects) as soon as the first promise settles → first promise (fastest) to finish determines the result.
Example 1: First promise to resolve
const fast = Promise.resolve('I win!');
const slow = new Promise(resolve => {
setTimeout(() => resolve('Too slow'), 1000);
});
Promise.race([slow, fast])
.then(result => console.log(result)); // 'I win!'
Example 2: First promise to reject
const fast = Promise.reject('I fail first!');
const slow = Promise.resolve('Success but too late');
Promise.race([fast, slow])
.then(result => console.log('Success:', result))
.catch(error => console.log('Error:', error)); // 'Error: I fail first!'
Use Case
function fetchWithTimeout(url, timeoutMs) {
const fetchPromise = fetch(url);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Timeout')), timeoutMs);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
// Fetch data, but fail if it takes longer than 5 seconds
fetchWithTimeout('/api/data', 5000)
.then(response => console.log('Got data!'))
.catch(error => console.log('Failed or timed out:', error.message));
IV. APIs
API ('Application Programming Interfaces') is a server that exists specifically to serve data to external websites or apps. Think of it as a waiter in a restaurant:
- You (your website) ask for specific data
- The API (waiter) goes to the kitchen (database)
- Returns the data you requested in a standardized format
Types of APIs
Internal APIs: Serve data for a specific website
- Blog posts for your blog
- User profiles for your social app
- Game scores for your game
Public APIs: Open services that serve data to anyone
- Weather data
- Stock prices
- Random cat pictures
- Currency exchange rates
1. How APIs Work
APIs are accessed through URLs called endpoints. You make a request to a specific URL, and the API sends back data in a structured format (usually [[07. Asynchronous JavaScript#JSON|JSON]]).
// Anatomy of an API request URL
https://api-service.com/endpoint?parameter1=value1¶meter2=value2
│────────────────────┘ │──────┘ │─────────────────────────────────┘
Base URL Endpoint Query Parameters
- Base URL: The main address of the API service
- Endpoint: Specific function or data type you want to access
- Query Parameters: Additional information to customize your request
a. Complete API Request
Step 1: Request weather data for London
GET https://weather.api.com/current?city=london&units=metric
Step 2: API processes your request
- Validates your request
- Checks your API key (if required)
- Queries the database
- Formats the response
Step 3: API sends back data in JSON format.
{
"city": "London",
"temperature": 18,
"humidity": 65,
"conditions": "Partly cloudy",
"timestamp": "2024-01-15T14:30:00Z"
}
// 1. The URL breakdown
const baseUrl = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services";
const endpoint = "/timeline/london";
const apiKey = "YOUR_KEY_HERE";
const fullUrl = `${baseUrl}${endpoint}?key=${apiKey}`;
// 2. Make the request
fetch(fullUrl)
.then(response => response.json())
.then(weatherData => {
// 3. Use the returned data
console.log(`Temperature in London: ${weatherData.days[0].temp}°F`);
});
b. JSON
Resources
JSON (JavaScript Object Notation) is a standardized format for structuring data. It is heavily based on the syntax for JavaScript objects.
→ it is essentially the universal format for transmitting data on the web, and can be seen very often when working with external servers or APIs.
// API returns this JSON string:
'{"name": "John", "age": 30, "city": "London"}'
// .json() converts it to a JavaScript object:
{name: "John", age: 30, city: "London"}
c. REST APIs — HTTP Methods
REST APIs (most common) use standard HTTP methods:
- GET: Retrieve data (e.g., get user profile)
- POST: Create new data (e.g., create new user)
- PUT: Update existing data (e.g., update user profile)
- DELETE: Remove data (e.g., delete user account)
2. API Keys and Authentication
Resource: Public APIs
Most APIs require an API key — a random and unique identifier that:
- Tracks usage: How much data you're requesting
- Prevents abuse: Stops people from overloading the server
- Enables billing: Allows paid tiers for heavy usage
- Provides security: Controls who can access the data
a. API Keys in Practice
Step 1: A unique key is generated. "your-unique-api-key-abc123xyz789"
Step 2: Include the unique key in the requests. Different APIs require the key in different places.
// Method 1: Query parameter (most common)
const url = `https://api.service.com/data?api_key=${yourApiKey}&city=london`;
// Method 2: Request headers
fetch('https://api.service.com/data', {
headers: {
'Authorization': `Bearer ${yourApiKey}`,
'X-API-Key': yourApiKey
}
});
// Method 3: Request body (for POST requests)
fetch('https://api.service.com/data', {
method: 'POST',
body: JSON.stringify({
api_key: yourApiKey,
data: 'your data here'
})
});
Step 3: API validates your key. It checks whether:
- This is a valid and active (not revoked) key.
- If the key had exceeded its rate limits.
- If the key have permission for this endpoint.
Every API provider will provide detailed documentation to let you know the required parameters and the formatting for the request.
b. API Security Considerations
The Problem with Exposed API Keys
- Bots scan GitHub for exposed API keys
- Bad actors can steal your paid API access
- Your bill increases from unauthorized usage
- Rate limits apply to your key, affecting your app
// ❌ Bad: API key visible in frontend code
const apiKey = "abc123secretkey";
fetch(`https://api.example.com/data?key=${apiKey}`);
// Anyone can see your API key in:
// - Browser developer tools
// - View source
// - Network requests
// - GitHub repositories
- For projects that run locally (no deployment), one can avoid exposing the API key by storing it in an
.envfile and.gitignorethe.env.- However, this isn't good practice and local doesn't mean private because frontend builds compile environment variables into JavaScript bundles that anyone using the app can inspect.
- While practicing, it's better to use mock data or free public APIs that don't need authentication.
- In production, API keys should be handled on the backend. This is separation of concerns (frontend UI vs backend logic).
3. API Requests with fetch()
1. Old way (XMLHttpRequest)
// This is painful - don't use this!
if (window.XMLHttpRequest) {
request = new XMLHttpRequest();
} else if (window.ActiveXObject) {
try {
request = new ActiveXObject('Msxml2.XMLHTTP');
} catch (e) {
try {
request = new ActiveXObject('Microsoft.XMLHTTP');
} catch (e) {}
}
}
// Open, send.
request.open('GET', 'https://url.com/some/url', true);
request.send(null);
2. Modern way - fetch()
// Basic structure
// URL (required), options (optional)
fetch(url, options)
.then(response => {
// Handle the response
})
.catch(error => {
// Handle errors
});
fetch('https://url.com/some/url')
.then(response => response.json())
.then(data => {
// Use the data
})
.catch(error => {
// Handle errors
});
Important: fetch() returns a Promise that resolves to a Response object, NOT the actual data.
fetch('https://api.example.com/data')
.then(response => {
console.log(response); // Response object, not the data!
console.log(response.status); // 200
console.log(response.ok); // true
// To get the actual data, you need another step:
return response.json(); // This also returns a Promise!
})
.then(data => {
console.log(data); // Now this is the actual data
});
4. CORS (Cross-Origin Resource Sharing)
By default, browsers block requests between different origins.
// These are ALL different origins:
'https://mysite.com' // Your website
'https://api.google.com' // Different domain
'http://mysite.com' // Different protocol (http vs https)
'https://mysite.com:8080' // Different port
'https://subdomain.mysite.com' // Different subdomain
// If your site is https://myapp.com, these work without CORS:
fetch('/api/users') // Same origin
fetch('https://myapp.com/api/data') // Same origin
fetch('https://myapp.com:443/api') // Same origin (443 is default HTTPS port)
This security exists to prevent malicious sites from:
- Reading your banking data while you’re logged in
- Making requests on your behalf to any site
- Stealing personal information from other tabs
Most commonly, you can add a JavaScript object for option to your fetch function, telling the browser to handle CORS properly.
fetch('https://api.external.com/data', {
mode: 'cors' // This usually fixes the issue
})
.then(response => response.json())
.then(data => console.log(data));
5. Basic HTML Implementation Pattern
Example In this example, when the Get Data button gets clicked, the result will be returned from the API server, and displayed on the screen.
Key Pattern:
- Get DOM elements
- Add event listener
- Make fetch request
- Update DOM with results
- Handle errors
- HTML Structure
<!DOCTYPE html>
<html>
<head>
<title>API Demo</title>
</head>
<body>
<button id="fetch-btn">Get Data</button>
<div id="result"></div>
<script>
// JavaScript goes here
</script>
</body>
</html>
- JavaScript Structure
const button = document.getElementById('fetch-btn');
const resultDiv = document.getElementById('result');
button.addEventListener('click', () => {
fetch('https://api.example.com/data?key=YOUR_API_KEY', {
mode: 'cors'
})
.then(response => response.json())
.then(data => {
// Display the data in HTML
resultDiv.innerHTML = `<p>Result: ${data.message}</p>`;
})
.catch(error => {
resultDiv.innerHTML = `<p>Error: ${error.message}</p>`;
});
});
6. API Error Handling
a. Common HTTP Status Codes
| Code | Meaning | What to do/look at |
|---|---|---|
| 200-299 | Success | Process the data |
| 400 | Bad Request | Check your parameters |
| 401 | Unauthorized | Check API key |
| 404 | Not Found | Resource doesn't exist |
| 429 | Rate Limited | Wait and retry |
| 500 | Server Error | Try backup or show error |
b. Error Handling
fetch() only throws for network failures, NOT HTTP errors!
// These WON'T throw errors (fetch succeeds)
fetch('/api/nonexistent'); // 404 - fetch succeeds, but response.ok = false
fetch('/api/server-error'); // 500 - fetch succeeds, but response.ok = false
// These WILL throw errors (fetch fails)
fetch('https://invalid-domain.xyz'); // Network error - throws
// No internet connection - throws
// Server completely down - throws
→ In order to catch all the HTTP errors, we need to check response.ok.
response.ok
async function fetchWeatherSafely(city) {
try {
const response = await fetch(`/api/weather/${city}`);
// Check if HTTP request succeeded
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
// Catches both network errors AND HTTP errors
console.error('Fetch failed:', error.message);
throw error;
}
}
Error Handling Pattern
async function robustFetch(url) {
try {
const response = await fetch(url);
// Handle HTTP errors
if (!response.ok) {
if (response.status === 404) {
throw new Error('Data not found');
} else if (response.status === 401) {
throw new Error('Invalid API key');
} else if (response.status >= 500) {
throw new Error('Server error - try again later');
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
}
// Parse JSON (this can also throw if response isn't valid JSON)
const data = await response.json();
return data;
} catch (error) {
// Catches network errors, HTTP errors, and JSON parsing errors
if (error.name === 'TypeError') {
throw new Error('Network error - check your connection');
}
throw error; // Re-throw other errors
}
}
V. async and await — Modern Promise Syntax
Resources:
async and await is a syntax that makes Promise-based code look and behave more like synchronous code. Under the hood, it’s still using Promises — this is just a cleaner way to write them.
a. Event Loop Visualization
Example:- All the synchronous operations in the call stack gets executed immediately.
- The
awaitpauses the function and schedules the promise for the microtask queue to run later, after the call stack is empty. - When
console.log('F:', promise)gets executed, the promise hasn’t finished running yet. Theawaitinside theexample()function and the follow-upconsole.logare still waiting → the state is<pending>.
console.log('A'); // 1. Prints A immediately
async function example() {
console.log('B'); // 3. Prints B immediately
const result = await Promise.resolve('Hello'); // 4. PAUSES here, schedules microtask
console.log('C:', result); // 7. Prints C: Hello (resumes after await)
return 'Done'; // 8. Returns 'Done'
}
console.log('D'); // 2. Prints D immediately
const promise = example(); // Starts async function
console.log('E'); // 5. Prints E immediately
console.log('F:', promise); // 6. Prints F: Promise { <pending> }
// 7-8. Microtask runs, function resumes
// Output: A, D, B, E, F: Promise { <pending> }, C: Hello
b. async await vs. Promise Syntax
async function example() {
console.log('B');
const result = await Promise.resolve('Hello');
console.log('C:', result);
return 'Done';
}
function example() {
console.log('B');
return Promise.resolve('Hello')
.then(result => {
console.log('C:', result);
return 'Done';
});
}
1. async
The async keyword is what lets the JavaScript engine know that you are declaring an asynchronous function.
async function example() {
return "Hello World";
}
// This is equivalent to:
function example() {
return Promise.resolve("Hello World");
}
// Both return a Promise that resolves to "Hello World"
console.log(example()); // Promise { <fulfilled>: "Hello World" }
When a function is declared with async, it automatically returns a promise.
→ Returning in an async function is the same as resolving a promise. Likewise, throwing an error will reject the promise.
// Function declaration
async function fetchData() {
// Can use await inside here
}
// Function expression
const fetchData = async function() {
// Can use await inside here
};
// Arrow function
const fetchData = async () => {
// Can use await inside here
};
2. await
await pauses the async function immediately. Then the awaited Promise gets scheduled for the microtask queue
→ Control returns to the call stack (other synchronous code can run)
Once the call stack empties, the microtask queue processes. The async function resumes from where it paused.
Each
.then()in Promise chains = oneawaitin async/await!
a. await Code Execution
Key Insight:
- await resolves → Function pauses, then continues from where it left off
- await rejects → Function stops immediately, remaining code is skipped
Success Case — Function Resumes:
async function success() {
console.log('Before await');
const result = await Promise.resolve('Success!');
console.log('After await:', result); // ✅ This DOES run
console.log('Even later'); // ✅ This also runs
}
Failure Case — Function Stops:
async function failure() {
console.log('Before await');
const result = await Promise.reject('Error!');
console.log('After await:', result); // ❌ This NEVER runs
console.log('Even later'); // ❌ This NEVER runs
}
The await keyword is used to get a value from a function where you would normally use .then(). Instead of calling .then() after the asynchronous function, you would assign a variable to the result using await.
// Without await - you get a Promise object
const userPromise = fetch('/api/user');
console.log(userPromise); // Promise { <pending> }
// With await - you get the actual response
const userResponse = await fetch('/api/user');
console.log(userResponse); // Response object with status, headers, etc.
await unwraps Promises, similar to how .then() works, but cleaner.
// These are equivalent:
// Promise version
fetch('/api/data')
.then(response => response.json())
.then(data => console.log(data));
// Async/await version
const response = await fetch('/api/data');
const data = await response.json();
console.log(data);
b. Important restrictions
awaitonly works insideasyncfunctionsawaitonly works with Promises (or Promise-like objects)
// ❌ This doesn't work
function regularFunction() {
const data = await fetch('/api/data'); // SyntaxError!
}
// ✅ This works
async function asyncFunction() {
const data = await fetch('/api/data'); // Perfect!
}
- Promise chaining:
function getUserProfile(userId) {
return fetch(`/api/users/${userId}`)
.then(response => {
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
})
.then(userData => {
return fetch(`/api/users/${userId}/posts`);
})
.then(response => response.json())
.then(posts => {
return {
user: userData, // ❌ Error! THIS userData is out of scope
posts: posts
};
})
.catch(error => {
console.error('Error:', error);
});
}
async,await: Withasync/await, variables stay in scope naturally, making complex operations much easier to manage.
async function getUserProfile(userId) {
try {
// Get user data
const userResponse = await fetch(`/api/users/${userId}`);
if (!userResponse.ok) {
throw new Error('User not found');
}
const userData = await userResponse.json();
// Get user posts
const postsResponse = await fetch(`/api/users/${userId}/posts`);
const posts = await postsResponse.json();
// Both userData and posts are accessible here!
return {
user: userData,
posts: posts
};
} catch (error) {
console.error('Error:', error);
}
}
Note: Think of
asyncawaitas:
async= declaring "I'm going to do asynchronous stuff in here"await= the actual "wait for this specific thing to finish"
3. Error Handling with try and catch
Async/await uses try/catch blocks instead of .catch() methods, which feels more natural.
async function withTryCatch() {
console.log('B');
try {
await Promise.reject('Error!');
console.log('C'); // ❌ Doesn't run (error jumps to catch)
} catch (error) {
console.log('D:', error); // ✅ Catches the error
}
console.log('E'); // ✅ Runs because catch HANDLED the error!
}
// Output: B, D: Error, E
Error handling is very important and should be done at every step. Without efficient error handling, operations, functions can stop or not run due to errors.
// Version A: No error handling
async function versionA() {
console.log('A1');
await Promise.reject('Error!');
console.log('A2'); // ❌ Doesn't run
}
// Version B: With try/catch
async function versionB() {
console.log('B1');
try {
await Promise.reject('Error!');
console.log('B2'); // ❌ Doesn't run
} catch (error) {
console.log('B3: caught'); ✅ Catches the error
}
console.log('B4'); // Allows function to keep running
}
Error handling approaches
// Approach 1: Handle inside the function
async function getUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
return await response.json();
} catch (error) {
console.error('Error in getUser:', error);
return null; // Return default value
}
}
// Approach 2: Let the caller handle it
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
return await response.json();
// No try/catch - errors bubble up to caller
}
// Caller handles the error
async function displayUser(id) {
try {
const user = await getUser(id);
console.log(user.name);
} catch (error) {
console.error('Failed to display user:', error);
}
}
4. Common async await patterns
Pattern 1: Sequential vs. Parallel execution
// Sequential (slower) - operations happen one after another
async function getDataSequential() {
const user = await fetch('/api/user'); // Wait 500ms
const posts = await fetch('/api/posts'); // Wait another 500ms
// Total: ~1000ms
return { user, posts };
}
// Parallel (faster) - operations happen simultaneously
async function getDataParallel() {
const userPromise = fetch('/api/user'); // Start immediately
const postsPromise = fetch('/api/posts'); // Start immediately
const user = await userPromise; // Wait for user
const posts = await postsPromise; // Posts might already be done!
// Total: ~500ms (the slower of the two)
return { user, posts };
}
// Even better: Promise.all for truly parallel operations
async function getDataBest() {
const [user, posts] = await Promise.all([
fetch('/api/user'),
fetch('/api/posts')
]);
// Total: ~500ms, and cleaner code
return { user, posts };
}
Pattern 2: Handling multiple async operations
Notice how we can mix async, await with .then().
async function processUsers(userIds) {
const users = [];
// ❌ Wrong: Sequential processing (slow)
for (const id of userIds) {
const user = await fetch(`/api/users/${id}`);
users.push(await user.json());
}
// ✅ Better: Parallel processing (fast)
const userPromises = userIds.map(id =>
fetch(`/api/users/${id}`).then(r => r.json())
);
return await Promise.all(userPromises);
}
5. async await with DOM events
// Clean pattern for handling user interactions
async function handleFormSubmit(event) {
event.preventDefault();
try {
// Show loading state
showLoadingSpinner();
// Collect form data
const formData = new FormData(event.target);
// Submit to server
const response = await fetch('/api/submit', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Submission failed');
}
const result = await response.json();
// Show success message
showSuccessMessage('Form submitted successfully!');
} catch (error) {
// Show error message
showErrorMessage(`Error: ${error.message}`);
} finally {
// Always hide loading spinner
hideLoadingSpinner();
}
}
// Attach to form
document.getElementById('myForm').addEventListener('submit', handleFormSubmit);