You need Node installed, and to know how to run a
.js file. If node --version in a terminal
prints a version number, you are ready. If it does not — or if you
have never opened a terminal — do
Getting Started with
JavaScript first; it installs Node and runs your first program in about
fifteen minutes. Nothing else is needed.
Synchronous vs Asynchronous
By default, JavaScript runs code one line at a time, from top to bottom. This is called synchronous execution. Each line must finish before the next one starts.
// Synchronous — each line runs in order
console.log("First");
console.log("Second");
console.log("Third");
// Output: First, Second, Third
But what happens when an operation takes time — like fetching data from a server, reading a file, or waiting for a timer? If JavaScript waited for every slow operation to finish, the entire page would freeze.
// Asynchronous — some operations happen "later"
console.log("First");
setTimeout(function() {
console.log("Second (after 2 seconds)");
}, 2000);
console.log("Third");
// Output: First, Third, Second (after 2 seconds)
// JavaScript did NOT wait for the timer!
Asynchronous code allows JavaScript to start a long-running operation and continue executing the rest of the code without waiting. When the operation finishes, a callback function runs with the result.
Callbacks and Callback Hell
A callback is a function passed as an argument to another function. The callback runs when the asynchronous operation completes.
// Simple callback example
function fetchData(callback) {
setTimeout(function() {
const data = { name: "Alice", age: 30 };
callback(data); // Call the callback with the result
}, 1000);
}
fetchData(function(result) {
console.log(result); // { name: "Alice", age: 30 }
});
Callbacks work fine for simple cases, but when you need to chain multiple asynchronous operations, you end up with deeply nested code known as "callback hell":
// Callback hell — deeply nested, hard to read
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getOrderDetails(orders[0].id, function(details) {
getShippingInfo(details.shippingId, function(shipping) {
console.log("Shipping:", shipping);
// Even more nesting if needed...
}, function(error) {
console.error("Shipping error:", error);
});
}, function(error) {
console.error("Details error:", error);
});
}, function(error) {
console.error("Orders error:", error);
});
}, function(error) {
console.error("User error:", error);
});
This pyramid of doom is hard to read, hard to debug, and hard to maintain. Promises were created to solve this problem.
Promises
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It has three states:
- Pending: The operation is still in progress
- Fulfilled (resolved): The operation completed successfully
- Rejected: The operation failed with an error
// Creating a Promise
const myPromise = new Promise(function(resolve, reject) {
// Simulate an async operation
const success = true;
setTimeout(function() {
if (success) {
resolve("Operation succeeded!"); // Fulfilled
} else {
reject("Operation failed!"); // Rejected
}
}, 1000);
});
// Using a Promise
myPromise
.then(function(result) {
console.log(result); // "Operation succeeded!"
})
.catch(function(error) {
console.error(error); // Runs if rejected
});
// A more realistic example
function fetchUser(id) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
if (id > 0) {
resolve({ id: id, name: "Alice", email: "alice@example.com" });
} else {
reject(new Error("Invalid user ID"));
}
}, 500);
});
}
fetchUser(1)
.then(user => console.log("Found:", user.name))
.catch(error => console.error(error.message));
Promise Chaining
Promises can be chained — each .then() returns a new Promise, allowing
you to perform sequential async operations without nesting.
// Chaining replaces callback hell
fetchUser(1)
.then(user => {
console.log("User:", user.name);
return fetchOrders(user.id); // Returns a new Promise
})
.then(orders => {
console.log("Orders:", orders.length);
return fetchOrderDetails(orders[0].id);
})
.then(details => {
console.log("Details:", details);
})
.catch(error => {
// One catch handles errors from any step
console.error("Something went wrong:", error.message);
});
Promise.all
Run multiple Promises in parallel and wait for all of them to finish:
const promise1 = fetch("/api/users");
const promise2 = fetch("/api/posts");
const promise3 = fetch("/api/comments");
Promise.all([promise1, promise2, promise3])
.then(responses => {
console.log("All requests completed!");
// responses is an array of all results
return Promise.all(responses.map(r => r.json()));
})
.then(([users, posts, comments]) => {
console.log("Users:", users.length);
console.log("Posts:", posts.length);
console.log("Comments:", comments.length);
})
.catch(error => {
// If ANY promise rejects, catch fires immediately
console.error("One request failed:", error);
});
Promise.race
Returns the result of whichever Promise finishes first:
// Useful for timeouts
const fetchWithTimeout = Promise.race([
fetch("/api/slow-endpoint"),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Request timed out")), 5000)
)
]);
fetchWithTimeout
.then(response => console.log("Got response in time!"))
.catch(error => console.error(error.message));
Async/Await
async/await is a cleaner syntax for working with Promises.
It makes asynchronous code look and behave like synchronous code.
// Mark a function as async
async function loadUserData() {
// await pauses execution until the Promise resolves
const user = await fetchUser(1);
console.log("User:", user.name);
const orders = await fetchOrders(user.id);
console.log("Orders:", orders.length);
const details = await fetchOrderDetails(orders[0].id);
console.log("Details:", details);
return details; // async functions always return a Promise
}
// Call the async function
loadUserData()
.then(details => console.log("Done!"))
.catch(error => console.error(error));
async/await does not replace Promises — it uses
them under the hood. An async function always returns a Promise.
The await keyword simply pauses execution until a Promise resolves,
making the code easier to read. You can mix both styles as needed.
// Parallel execution with async/await
async function loadDashboard() {
// Start all requests simultaneously
const [users, posts, stats] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/stats").then(r => r.json())
]);
console.log("Users:", users.length);
console.log("Posts:", posts.length);
console.log("Stats:", stats);
}
// Common mistake: sequential instead of parallel
async function loadDashboardSlow() {
// These run one after another — slower!
const users = await fetch("/api/users").then(r => r.json());
const posts = await fetch("/api/posts").then(r => r.json());
const stats = await fetch("/api/stats").then(r => r.json());
}
The Fetch API
fetch() is the modern way to make HTTP requests in JavaScript. It
returns a Promise that resolves to the Response object.
GET Requests
// Basic GET request
const response = await fetch("https://api.example.com/users");
const users = await response.json(); // Parse JSON body
console.log(users);
// With query parameters
const query = new URLSearchParams({ page: 1, limit: 10 });
const response = await fetch(`https://api.example.com/users?${query}`);
const data = await response.json();
POST Requests
// Sending JSON data
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
name: "Alice",
email: "alice@example.com"
})
});
const newUser = await response.json();
console.log("Created user:", newUser);
Other HTTP Methods
// PUT — update a resource
await fetch("/api/users/1", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice Updated" })
});
// DELETE — remove a resource
await fetch("/api/users/1", {
method: "DELETE"
});
// PATCH — partial update
await fetch("/api/users/1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "newemail@example.com" })
});
Checking Response Status
const response = await fetch("/api/data");
// response.ok is true for status codes 200-299
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
// Common response properties
console.log(response.status); // 200
console.log(response.statusText); // "OK"
console.log(response.headers); // Headers object
console.log(response.url); // The final URL (after redirects)
Error Handling with Async Code
try/catch with async/await
async function fetchUserSafe(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`User not found (${response.status})`);
}
const user = await response.json();
return user;
} catch (error) {
if (error.name === "TypeError") {
// Network error (no internet, server down, etc.)
console.error("Network error:", error.message);
} else {
// Application error (404, 500, etc.)
console.error("Error:", error.message);
}
return null; // Return a safe default
}
}
fetch() only rejects on network failures (no internet, DNS errors).
It does NOT reject on HTTP errors like 404 or 500 — those are considered successful
responses. Always check response.ok or response.status
before using the data. Unhandled errors cause silent failures that are difficult
to debug.
.catch() with Promises
// Using .catch() with Promise chains
fetch("/api/data")
.then(response => {
if (!response.ok) throw new Error("Request failed");
return response.json();
})
.then(data => {
console.log("Data:", data);
})
.catch(error => {
console.error("Error:", error.message);
})
.finally(() => {
// Runs whether the promise resolved or rejected
console.log("Request complete");
hideLoadingSpinner();
});
Handling Multiple Errors
async function loadPageData() {
try {
const [users, posts] = await Promise.all([
fetch("/api/users").then(r => {
if (!r.ok) throw new Error("Failed to load users");
return r.json();
}),
fetch("/api/posts").then(r => {
if (!r.ok) throw new Error("Failed to load posts");
return r.json();
})
]);
return { users, posts };
} catch (error) {
console.error(error.message);
return { users: [], posts: [] }; // Safe defaults
}
}
// Promise.allSettled — never rejects, reports all results
const results = await Promise.allSettled([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/broken-endpoint").then(r => r.json())
]);
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`Request ${index} succeeded:`, result.value);
} else {
console.log(`Request ${index} failed:`, result.reason);
}
});
Practical Example: API Data Loader
Let us build a complete data loader that fetches user profiles from an API and displays them on a page with loading states and error handling.
// HTML:
// <div id="user-app">
// <input type="text" id="user-id" placeholder="Enter user ID">
// <button id="load-btn">Load User</button>
// <div id="status"></div>
// <div id="user-card"></div>
// </div>
const loadBtn = document.querySelector("#load-btn");
const userIdInput = document.querySelector("#user-id");
const statusDiv = document.querySelector("#status");
const userCard = document.querySelector("#user-card");
function showStatus(message, type) {
statusDiv.textContent = message;
statusDiv.className = type; // "loading", "error", or "success"
}
function renderUser(user) {
userCard.innerHTML = `
<div class="card">
<h3>${user.name}</h3>
<p>Email: ${user.email}</p>
<p>Company: ${user.company.name}</p>
<p>City: ${user.address.city}</p>
</div>
`;
}
async function loadUser() {
const userId = userIdInput.value.trim();
if (!userId) {
showStatus("Please enter a user ID", "error");
return;
}
// Show loading state
showStatus("Loading...", "loading");
userCard.innerHTML = "";
loadBtn.disabled = true;
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (!response.ok) {
if (response.status === 404) {
throw new Error("User not found");
}
throw new Error(`Server error (${response.status})`);
}
const user = await response.json();
renderUser(user);
showStatus(`Loaded user: ${user.name}`, "success");
} catch (error) {
if (error.name === "TypeError") {
showStatus("Network error — check your connection", "error");
} else {
showStatus(error.message, "error");
}
} finally {
loadBtn.disabled = false;
}
}
// Event listeners
loadBtn.addEventListener("click", loadUser);
userIdInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") loadUser();
});
This example demonstrates fetch requests, proper error handling, loading states, and async/await working together in a practical UI component.
Now Do It Yourself: Five Steps
Waiting is the whole subject, so these steps make you watch it happen: the
order things really run in, one wait, two waits done wrongly and then rightly,
a failure caught properly, and the single most common async bug there is. They
use a timer rather than a real network request on purpose — a timer needs
no internet, and it gives you the same numbers every run, so you can tell a
lesson from a slow connection. fetch behaves exactly the same way;
only the thing you are waiting for changes. Every output below is exactly what
Node printed.
Go: open a terminal, cd to a folder you can write in, and create a file called later.js.
Do: type these three lines and run node later.js.
console.log("first");
setTimeout(() => console.log("second (after 0 ms)"), 0);
console.log("third");
You should see: first, then third, then second (after 0 ms) — in that order, every time. Zero milliseconds does not mean “now”; it means “as soon as the current work is finished”. JavaScript runs your straight-line code to the end first, and only then picks up anything that was scheduled. That single rule explains most async confusion.
If not: if you see second in the middle, you are not running this in a normal JavaScript engine — check you saved the file and ran the one you edited. If nothing prints at all, the file is empty or you ran a different filename; ls in that folder and look.
Go: same file, replace the contents.
Do: type these ten lines and run it.
function wait(ms, label) {
return new Promise(resolve => setTimeout(() => resolve(label), ms));
}
async function main() {
console.log("starting");
const result = await wait(300, "tea is ready");
console.log(result);
console.log("finished");
}
main();
You should see: starting, a visible pause of about a third of a second, then tea is ready, then finished. A promise is a receipt for a value that has not arrived yet; resolve(label) is what fills it in, and await is what unwraps it. Note where async had to go: await is only legal inside a function marked async.
If not: SyntaxError: await is only valid in async functions and the top level bodies of modules means you used await in an ordinary function — add async in front of function. If the program ends instantly with no pause, you left the brackets off main: main; merely mentions the function, main(); runs it.
Go: same file, replace the contents.
Do: type these thirteen lines and run it.
function wait(ms, label) {
return new Promise(resolve => setTimeout(() => resolve(label), ms));
}
async function main() {
let start = Date.now();
await wait(300, "a");
await wait(300, "b");
console.log("one after the other:", Math.round((Date.now() - start) / 100) * 100, "ms");
start = Date.now();
await Promise.all([wait(300, "a"), wait(300, "b")]);
console.log("both at once:", Math.round((Date.now() - start) / 100) * 100, "ms");
}
main();
You should see: one after the other: 600 ms, then both at once: 300 ms. Same two jobs, half the time. Two awaits on separate lines mean the second one has not even started until the first finishes; Promise.all starts both immediately and waits for the slower one. When a page feels slow, this is very often why — independent requests queued behind each other for no reason.
If not: if both lines report roughly the same number, check that the second block really uses Promise.all([...]) with both calls inside one array. Writing await in front of each call inside the array puts you straight back to single file. Small variations like 610 are normal — timers are approximate, which is why the numbers are rounded to the nearest hundred.
Go: same file, replace the contents.
Do: type these twenty lines and run it.
function loadUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 1) resolve({ id: 1, name: "Sam" });
else reject(new Error(`no user with id ${id}`));
}, 100);
});
}
async function main() {
try {
const user = await loadUser(1);
console.log("got:", user.name);
const missing = await loadUser(99);
console.log("never reached:", missing);
} catch (err) {
console.log("caught:", err.message);
} finally {
console.log("done either way");
}
}
main();
You should see: got: Sam, then caught: no user with id 99, then done either way. The line printing never reached never runs: the moment an awaited promise rejects, control jumps straight to catch, skipping the rest of the try. finally runs whichever way it went, which is where cleanup belongs — closing a file, hiding a loading spinner.
If not: take the try/catch away and Node prints the error with a full stack trace and exits without ever reaching done either way. That is what an unhandled rejection looks like, and in a browser it is the same failure with a red console message instead. If err.message prints undefined, something rejected with a plain string rather than new Error(...) — always reject with a real Error.
Go: same file, replace the contents.
Do: type these eleven lines and run it.
function wait(ms, label) {
return new Promise(resolve => setTimeout(() => resolve(label), ms));
}
async function main() {
const forgotten = wait(100, "tea");
console.log(forgotten);
const awaited = await wait(100, "tea");
console.log(awaited);
console.log(typeof forgotten.then);
}
main();
You should see: Promise { <pending> }, then tea, then function. The only difference between the two is the missing await. Memorise the first line. When a value prints as Promise { <pending> }, or a field on it is mysteriously undefined, you forgot an await — you are holding the receipt instead of the thing. The last line shows what a promise really is: an object with a .then method on it.
If not: if the first line prints tea as well, you added await to both. If you see Promise { 'tea' } instead of <pending>, the wait had already finished before the log ran — raise the 100 to 500 and the pending state becomes easy to catch.
Without scrolling up: a colleague reports that user.name is
undefined, yet logging user on the line before shows
Promise { <pending> }. What is wrong, and where?
Answer: the call that produced user is missing its
await. They are reading .name off the promise rather
than off the value inside it, and a promise has no name.
Now do it without the page: rewrite step 3’s first
block so that three waits of 300 ms finish in about 300 ms rather than 900.
One array, one Promise.all, one await — and
check the printed number, not your expectation.
Summary
- Synchronous code runs line by line; asynchronous code lets slow operations happen in the background
- Callbacks are simple but lead to deeply nested "callback hell" for sequential operations
- Promises represent future values — use
.then()for success and.catch()for errors - Promise chaining avoids nesting by returning new Promises from each
.then() async/awaitmakes asynchronous code look synchronous and is easier to readfetch()is the modern API for HTTP requests and returns a Promise- Always check
response.ok— fetch does not reject on HTTP errors - Use
try/catchwith async/await or.catch()with Promises for error handling
You can now handle asynchronous operations, fetch data from APIs, and build responsive applications. Next up: Modern ES6+ Features — learn the powerful syntax additions that make JavaScript more expressive and concise.