Why Asynchronous Code Exists
JavaScript runs your code on a single thread. There is exactly one line executing at any moment, and while it runs, nothing else can — no clicks handled, no rendering, no timers firing. That is why an accidental infinite loop freezes an entire browser tab rather than just slowing it down.
A single thread would make the language useless for the web if every slow operation blocked it. Fetching data from a server takes hundreds of milliseconds; reading a file, waiting for an animation, waiting for a user to choose something — all far too long to sit still for. So slow work is handed off to the browser, which performs it elsewhere, and your code carries straight on. When the work finishes, the browser hands the result back to you through a callback.
That is the whole idea of asynchronous programming, and it explains the surprise every beginner runs into: code does not run in the order it is written. A line that starts a network request finishes immediately, because starting a request is all it did. The line after it runs long before the data arrives, so a variable you expected to hold the response is still empty.
The original way to receive the result was a callback function. It works, and it stops working well as soon as one asynchronous step depends on another, because each step has to nest inside the previous one's callback. Five levels deep, with the same error check repeated at every level, is what people call callback hell — and promises exist to flatten exactly that shape.
console.log('1. start');
setTimeout(() => {
console.log('3. later, even with a delay of 0');
}, 0);
console.log('2. end');
// Prints 1, 2, 3 - not 1, 3, 2
// Callback nesting: correct, and painful to read or extend
function loadStudent(id, done) {
setTimeout(() => done(null, { id: id, name: 'Ananya' }), 100);
}
function loadMarks(student, done) {
setTimeout(() => done(null, [82, 74]), 100);
}
loadStudent(1, (err, student) => {
if (err) return console.error(err);
loadMarks(student, (err2, marks) => {
if (err2) return console.error(err2);
console.log(student.name, marks);
// a third step would nest one level deeper again
});
}); - "Asynchronous" does not mean "in parallel". Your JavaScript still runs one line at a time on one thread. What happens elsewhere is the waiting — the network request, the timer — and only the result comes back to your thread.
The Event Loop, and setTimeout(fn, 0)
Explaining the order of that output takes one small model, and it is far simpler than its reputation suggests. There is a call stack, where the currently running code lives. There is a queue of callbacks that are ready to run. And there is the event loop, which does exactly one thing: whenever the stack is empty, it takes the first waiting callback and pushes it onto the stack.
So setTimeout(fn, 0) does not mean "run this now". It means "put this in the queue immediately, and run it once the stack is empty" — which is after every remaining line of the current script, however many there are. The delay you pass is a minimum, not a guarantee: a 10-millisecond timer fires no earlier than 10 milliseconds, and later if the thread is busy.
There are in fact two queues, and the difference between them appears in interviews constantly. Promise callbacks go into the microtask queue; setTimeout callbacks go into the macrotask queue. After each macrotask, the event loop drains the entire microtask queue before taking the next macrotask. That is why a .then registered after a setTimeout(fn, 0) still runs before it.
The practical lesson is not the vocabulary. It is that anything you schedule runs only after the current function has finished completely. You therefore cannot "wait for" an asynchronous result by writing a loop, or by reading a variable on the next line — the loop would occupy the very thread the result needs. You have to say what should happen afterwards, and that is precisely what a promise lets you express.
console.log('A: synchronous');
setTimeout(() => console.log('D: macrotask'), 0);
Promise.resolve().then(() => console.log('C: microtask'));
console.log('B: synchronous');
// Order: A, B, C, D
// Both callbacks were scheduled before B ran, and both still waited.
// The promise callback wins because microtasks are drained first.
// This is why polling for a result can never work
let data = null;
setTimeout(() => { data = 'arrived'; }, 10);
// while (data === null) { } // would freeze the tab forever:
// the loop never releases the thread - A long synchronous calculation delays timers, promise callbacks, clicks and rendering alike, because all of them need the same thread. If a page stutters during a heavy loop, the loop is the cause — not the rendering.
What a Promise Is
A promise is an object representing a value that is not available yet. It is a placeholder you can hold on to, pass around and attach handlers to, and which will eventually be filled in — or will report that it could not be.
It has exactly three states. Pending: the work is still going. Fulfilled: the work succeeded and the promise holds a value. Rejected: the work failed and the promise holds a reason, which should always be an Error. A promise begins pending and moves once, to fulfilled or rejected, and can never change again afterwards. That one-way, one-time transition is what makes promises predictable in a way raw callbacks are not — a callback can be invoked twice by mistake, or never; a promise cannot settle twice.
You do not read a promise's value directly. You register a function to run once it settles, with .then() for success and .catch() for failure. There is no promise.value, and logging the promise itself shows you the object rather than the result — which is the most common early confusion, and the reason a beginner's console shows Promise { <pending> } where they expected their data.
Almost every modern browser API returns a promise: fetch, the clipboard API, dynamic import(). So although the next lesson replaces most .then chains with async and await, you still need to recognise promises — because what await waits for is exactly this object.
// Already fulfilled
const ok = Promise.resolve(42);
console.log(ok); // Promise { 42 } - the object, not 42
ok.then(value => console.log(value)); // 42
// Already rejected
const bad = Promise.reject(new Error('Server unavailable'));
bad.catch(err => console.log('caught:', err.message));
// Pending, settled after a delay
const later = new Promise(resolve => {
setTimeout(() => resolve('done'), 200);
});
console.log(later); // Promise { <pending> }
later.then(v => console.log(v)); // 'done', about 200ms later - Once a promise has settled, attaching a handler later still works — you get the already-known result on the next microtask. Promises are not events: you cannot "miss" one by subscribing too late.
then, catch, finally and Chaining
.then(onSuccess) registers a function to run with the fulfilled value. The part that matters most is easy to miss: .then itself returns a new promise, resolved with whatever your function returned. That single fact is what makes chaining work at all.
So a chain reads as a sequence of steps, each .then receiving the previous step's return value. If a step returns a plain value, the next step gets that value. If it returns another promise, the chain waits for that promise before continuing — which is how you sequence dependent requests without nesting. Forgetting to return inside a .then is the classic bug here: the next step receives undefined and runs immediately, because there was nothing to wait for.
.catch(onError) handles a rejection from anywhere earlier in the chain, and that is the real advantage over nested callbacks — one handler at the end instead of an error check repeated at every level. A throw inside any .then lands there too, so your success path and your failure path can be written separately. Put the .catch last so that it covers everything above it.
.finally(fn) runs whichever way it went and receives no argument at all. It is where cleanup belongs: hide the spinner, re-enable the button, close the dialog. Doing that in both .then and .catch is exactly the duplication finally removes, and forgetting the .catch copy is precisely how a submit button ends up permanently disabled after one failed request.
function delay(ms, value) {
return new Promise(resolve => setTimeout(() => resolve(value), ms));
}
delay(100, { id: 1, name: 'Ananya' })
.then(student => {
console.log('got student:', student.name);
return delay(100, [82, 74]); // RETURN, so the chain waits
})
.then(marks => {
console.log('got marks:', marks);
return marks.reduce((a, b) => a + b, 0);
})
.then(total => {
console.log('total:', total); // 156
})
.catch(err => {
console.error('something failed:', err.message); // one handler for all
})
.finally(() => {
console.log('finished, either way');
});
// The classic bug: no return, so the next step gets undefined at once
delay(50, 'first')
.then(v => { delay(50, 'second'); }) // missing return
.then(v => console.log(v)); // undefined, immediately - A
.catchplaced in the middle of a chain handles errors from the steps above it and then lets the chain continue, because.catchalso returns a promise. That is occasionally what you want — a recoverable step — and is usually a mistake when you meant the chain to stop.
Creating a Promise Yourself
You will consume promises far more often than you create them, because almost every API hands you one already. When you do need to make one, the constructor takes a function that receives two callbacks: resolve to fulfil the promise with a value, and reject to fail it with a reason.
The function you pass runs immediately and synchronously, which surprises people who expect the promise to start later. Constructing a promise starts the work at once. What is deferred is only the handlers you attach to it afterwards.
The main legitimate reason to build one by hand is wrapping an older callback-based API so that it fits into modern code — turning setTimeout into a delay function is the smallest possible example, and wrapping a library that only takes callbacks is the realistic one. Always reject with an Error object rather than a string, so the failure carries a stack trace and behaves like every other error in your program.
Do not wrap something that already returns a promise. new Promise(resolve => fetch(url).then(resolve)) is a well-known anti-pattern: it adds a pointless layer, and it silently swallows every failure, because nothing in it ever calls reject. If it already returns a promise, use it directly.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Wrapping a callback-style API so it fits modern code
function loadStudent(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id: id, name: 'Ananya' });
} else {
reject(new Error('Invalid id: ' + id)); // an Error, not a string
}
}, 100);
});
}
loadStudent(1).then(s => console.log(s.name)); // 'Ananya'
loadStudent(-1).catch(e => console.log(e.message)); // 'Invalid id: -1'
// The function you pass runs immediately
new Promise(() => console.log('this prints straight away'));
// Anti-pattern - never wrap something that is already a promise
// return new Promise(resolve => fetch(url).then(resolve)); - Calling
resolvetwice does nothing the second time, and callingrejectafterresolveis ignored. The first settlement wins permanently, which is the property that makes promises safe to hand to code you do not control.
Running Several Promises at Once
The most common performance mistake in asynchronous code is doing independent things one after another. If three requests do not depend on each other, waiting for each in turn takes the sum of their times when it could have taken the longest of them. On a dashboard that is the difference between a page appearing in 300 milliseconds and appearing in 900.
Promise.all([...]) starts them together and gives you an array of results in the same order as the input array, regardless of which finished first. It rejects as soon as any single one rejects, discarding the others' results — which is the right behaviour when you need all of the data and have nothing useful to show without it.
Promise.allSettled([...]) waits for every one regardless of outcome and gives you an array of objects, each with a status plus either a value or a reason. Use it when partial success is acceptable: six dashboard widgets where one failing should not blank the whole page.
Promise.race([...]) settles with whichever finishes first, success or failure, which is how you add a timeout to a slow request. Promise.any([...]) takes the first success and rejects only if every one fails. Choosing between these four is really a question about what should happen when one thing goes wrong — and answering that deliberately, rather than by habit, is most of what good asynchronous design amounts to.
function delay(ms, value) {
return new Promise(resolve => setTimeout(() => resolve(value), ms));
}
function fail(ms, message) {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error(message)), ms);
});
}
// Sequential: about 300ms in total
delay(100, 'a')
.then(() => delay(100, 'b'))
.then(() => delay(100, 'c'))
.then(() => console.log('sequential done'));
// Parallel: about 100ms in total
Promise.all([delay(100, 'a'), delay(100, 'b'), delay(100, 'c')])
.then(values => console.log('all:', values)); // ['a', 'b', 'c']
// all rejects the moment one fails
Promise.all([delay(50, 'ok'), fail(10, 'boom')])
.catch(e => console.log('all failed:', e.message));
// allSettled reports on every one
Promise.allSettled([delay(50, 'ok'), fail(10, 'boom')])
.then(results => console.log(results.map(r => r.status)));
// ['fulfilled', 'rejected']
// race: the standard way to put a timeout on slow work
Promise.race([delay(500, 'slow result'), fail(200, 'Timed out')])
.then(v => console.log(v))
.catch(e => console.log(e.message)); // 'Timed out' - Three states: pending, then fulfilled or rejected — once, and permanently
.thenreturns a new promise, which is what makes chaining possible- Always
returninside a.then, or the next step getsundefined - One
.catchat the end covers every step above it .finallyfor cleanup that must happen either wayreject(new Error(...)), neverreject('a string')- Independent work belongs in
Promise.all, not in a chain - An unhandled rejection is a real error — every chain needs a
.catchsomewhere
- A promise chain with no
.catchanywhere produces an "Unhandled promise rejection" message in the console, and in Node it can end the process outright. Treat that message as a bug to fix, never as noise to scroll past.
