Most of what a web app does takes time. Fetching data from a server, reading a file, waiting for a user to click — none of it happens instantly. Asynchronous code is how JavaScript waits for slow work without freezing the page while it happens.
This is the skill that separates toy scripts from real applications. Every API call you make in React and every database query you run in Express will sit on the foundation from this lesson.
Synchronous vs asynchronous
Synchronous code runs one line at a time, in order, and each line waits for the previous one:
console.log('1')
console.log('2')
console.log('3')
// 1, 2, 3Asynchronous code schedules work to happen later, letting the program continue:
console.log('1')
setTimeout(() => {
console.log('2')
}, 1000)
console.log('3')
// 1, 3, 2setTimeout does not pause the program. It hands a function to the browser and says “run this in a second”. The program keeps going, prints 3, and when the timer fires, the callback prints 2.
JavaScript runs on one thread. Asynchronous code does not run two things at once — it schedules work and continues, then picks the result up when it is ready.
Callbacks
The oldest way to handle “do this when that finishes” is a callback — a function you pass in to be called later:
function loadUser(id, callback) {
setTimeout(() => {
callback({ id, name: 'Aki' })
}, 500)
}
loadUser(1, (user) => {
console.log(user.name) // Aki
})Callbacks work, but they nest badly. Three dependent operations become a pyramid:
loadUser(1, (user) => {
loadPosts(user.id, (posts) => {
loadComments(posts[0].id, (comments) => {
console.log(comments) // how do we handle an error at step 1?
})
})
})Each level needs its own error handling, the code reads top-to-bottom but runs inside-out, and adding a step means indenting everything again. This is “callback hell”.
Promises
A promise is an object that represents a value that will exist in the future. It has exactly three states:
pending ──▶ fulfilled (success: resolve was called)
└──▶ rejected (failure: reject was called)Create one with the Promise constructor, and consume it with .then() and .catch():
function loadUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, name: 'Aki' })
} else {
reject(new Error('Invalid id'))
}
}, 500)
})
}
loadUser(1)
.then((user) => console.log(user.name))
.catch((error) => console.error(error.message))The advantage: promises chain. Each .then can return another promise, producing a flat sequence instead of nested callbacks:
loadUser(1)
.then((user) => loadPosts(user.id))
.then((posts) => loadComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error('Something failed:', error.message))One .catch at the end handles a failure anywhere in the chain.
async / await
async/await is syntax on top of promises that makes asynchronous code read like synchronous code:
async function showUser() {
try {
const user = await loadUser(1)
console.log(user.name)
} catch (error) {
console.error(error.message)
}
}
showUser()Two rules:
- An
asyncfunction always returns a promise, even if you justreturn 5. awaitwaits for that promise and hands you the value — but it only pauses this function. The rest of the program keeps running.
Inside async functions you use normal try/catch for errors, which is easier to read than .catch() chains.
Sequential is not always necessary
Awaiting one request after another makes them run in sequence. If the requests do not depend on each other, run them together with Promise.all:
// Slow: the second request waits for the first
const user = await loadUser(1)
const posts = await loadPosts(1)
// Fast: both start at once
const [user, posts] = await Promise.all([loadUser(1), loadPosts(1)])Promise.all resolves when every promise resolves, and rejects as soon as any of them fails.
The Fetch API
fetch makes HTTP requests from JavaScript and returns a promise for a Response object:
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1')
const data = await response.json()
console.log(data.title)Two awaits, two different waits:
- The first waits for the response headers.
- The second waits for the body to arrive and parses it as JSON.
response.json() itself returns a promise — forget the await and you get a pending promise instead of data, which is one of the most common beginner bugs.
Sending data uses the same tool with a few options:
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title: 'Hello', body: 'World', userId: 1 }),
})
const created = await response.json()methoddefaults toGET; usePOST,PUT,PATCH, orDELETEas needed.headersdescribe what you are sending. Express will not parse a JSON body unlessContent-Typesays it is JSON.bodymust be a string, so objects are stringified.
Loading, error, success
Real interfaces have three states, and asynchronous code is where they come from:
const status = document.querySelector('#status')
async function loadPost() {
status.textContent = 'Loading…'
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const post = await response.json()
status.textContent = post.title
} catch (error) {
status.textContent = 'Could not load the post. Try again.'
console.error(error)
}
}
loadPost()Hold on to this pattern: set loading, try the request, handle success, catch failures. React will give it structure later, but the logic is the same.
Try it
Type this out by hand, then run it in the playground or in your own editor.
<script>
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function countdown() {
for (let i = 3; i >= 1; i--) {
console.log(i)
await wait(1000)
}
console.log('Go!')
}
countdown()
</script>Check your understanding
What does fetch() return?
A request returns 404 Not Found. What happens to the fetch promise?
Fetch and render data with all three UI states
Build index.html and script.js (loaded with defer). Use the free API at https://jsonplaceholder.typicode.com.
- Add a
<p id="status">and a<div id="post">to the page. - Write
async function loadPost(id)that sets the status toLoading…, fetcheshttps://jsonplaceholder.typicode.com/posts/${id}, checksresponse.ok, and renders the post title into the status area and the body text into#post. - On any failure, set the status to a friendly error message and log the real error to the console.
- Call
loadPost(1)on page load. - Test your error handling twice: first request
/posts/99999(a 404), then temporarily change the URL to an invalid domain to see a real network rejection. - Bonus: add a button that loads a random post ID (1–100).
- Bonus: use
Promise.allto fetch a post and its author (/users/1) at the same time, and render both.
Summary
- JavaScript is single-threaded; asynchronous code schedules work instead of blocking.
- Callbacks handle “later”, but nesting makes them hard to read and error-handle.
- Promises represent a future value in one of three states: pending, fulfilled, rejected.
async/awaitreads like synchronous code;asyncfunctions always return promises.- Independent requests should run together with
Promise.all. fetchresolves for HTTP errors — always checkresponse.ok, andawait response.json().- The loading → success → error pattern is the backbone of every data-driven interface.
You can now talk to servers. Next, modern JavaScript syntax — the shorthand that makes React and MERN code compact and readable.