How promises work inside
Learn how promise reaction lists, microtask jobs, thenable adoption, extra ticks, and rejection tracking shape JavaScript order.
- 01Follow a pending promiseExplain how
thensaves a handler on a waiting list, and why settling the promise puts handlers in a job line instead of calling them at once. - 02Spot the extra jobsPredict when resolving with a promise or a thenable needs a PromiseResolveThenableJob, so the handler runs a few jobs later.
- 03Handle rejections on purposeExplain what a browser and Node report when a rejected promise still has no handler after the waiting jobs have run.
A promise keeps a waiting list
A promise is an object that stands for a result that may not be ready yet. In your code, you make one, call then or catch on it, and wait. Inside, the promise keeps track of three things:
- its state: still waiting, done, or failed;
- its result: the value, or the reason it failed;
- a waiting list of functions that want the result.
A promise reaction is what then or catch saves on the promise: your function, plus a note of where its result should go. It never runs in the middle of the code that added it. It runs later, as a job: a small piece of work that waits in line until the code running now has finished.
const order = Promise.resolve("burger");order.then((food) => console.log(food));console.log("waiting");Line 1 makes a promise called order that already holds "burger". Line 2 adds a handler: a function that runs once the promise has its result. This one prints the food. Line 3 prints waiting.
The output is waiting, then burger. The handler on line 2 waited, even though the burger was already there.
waiting first, and then the waiting job prints burger.You learned how to use promises in Promises, Promise chaining, Microtasks, and Build a promise. This lesson uses the same food-order examples and shows what happens inside the promise.
States and saved handlers
A promise is always in one of three states:
- Pending: there is no result yet.
- Fulfilled: it has a value, like
"burger". - Rejected: it failed, and it has a reason, like
"sold out".
Every promise starts pending. It settles only once: it becomes fulfilled or rejected. After that, its state never changes again.
The next code is not the real Promise. It is a small model made for this lesson, so you can see the waiting list. It has three helpers:
makeOrder()makes a pending order. Itsthenadds a handler to the waiting list.order.fulfill(value)gives the order its value.runJobs()runs every job in the line, first to last.
const order = makeOrder(); order.then((food) => console.log("Asha gets", food));order.then((food) => console.log("Noah gets", food)); order.fulfill("burger");runJobs();Line 1 makes a pending order. Lines 3 and 4 add two handlers, one for Asha and one for Noah. The order has no food yet, so it saves both on its waiting list.
Line 6 fulfills the order with "burger". This does not run the handlers. It moves them into the line of jobs, Asha first. Line 7 runs the jobs. The output is Asha gets burger, then Noah gets burger.
then adds a handler to its waiting list. Line 6 fulfills the order. Both handlers move into the jobs line in the order they joined, so Asha runs before Noah.At a fast-food counter, you get a token number. Your friends wait for the same order. When the food is ready, the staff call each person in the order they joined. The food gets ready once. It does not get ready a second time.
- In real life: Your token number is on the screen, not ready yet
- In JavaScript: A promise is pending
- In real life: Several people wait for the same order
- In JavaScript: Each
thensaves one handler - In real life: Staff call people in the order they joined
- In JavaScript: Handlers run in the order they were added
- In real life: The food gets ready only once
- In JavaScript: A promise settles only once
In the real specification, a saved handler is called a reaction record. It holds two things:
- the function you passed to
then; - the new promise that
thengave back. That promise gets the function's result.
The model keeps only the function, because that is all this lesson needs.
Reaction jobs run the handlers later
When a saved handler is ready to run, JavaScript makes a PromiseReactionJob. That is the specification's name for "the job that runs one saved handler". It happens in two cases:
- A pending promise settles. Each handler on its waiting list becomes one job.
- You call
thenon a promise that has already settled. The new handler becomes a job straight away.
Either way, the job waits in line. Promise jobs wait in a line called the microtask queue. That line starts as soon as the code running now has finished.
const order = Promise.resolve("burger"); order.then((food) => console.log("first", food));console.log("after then");Line 1 makes an order that already holds "burger". Line 3 adds a handler. The order is ready, but the handler is not called here. It goes into the job line. Line 4 prints straight away. The output is after then, then first burger.
Replay the waiting-list model: handlers become jobs only when the pending order settles.
script
order.then((food) => console.log("Asha gets", food));order.then((food) => console.log("Noah gets", food)); order.fulfill("burger");runJobs();The step-through above replays the small model from the last section, one step at a time. It is a replay of the lesson's model, not a look inside a real JavaScript engine:
- Lines 3 and 4 save Asha, then Noah.
- Line 6 settles the order and fills the job line.
- Line 7 runs the line, so Asha comes before Noah.
Why wait at all? The code that settles a promise can finish its own work first. A handler can do many things: start more promise work, return another promise, or throw an error. None of that can break into the middle of the line that settled the promise.
Handlers run in the order they were added. That order only counts among jobs in the same line. It does not mean every browser event waits behind them. A later lesson on the microtask queue shows where these jobs sit next to timers and screen updates.
Thenables get one extra job
A thenable is any object (or function) that has a then method you can call. From the outside it looks like a promise, but it may not be a real one. Its then method is ordinary code that someone wrote. It might throw an error, or call back twice.
When you resolve a promise with a thenable, the promise does not keep the thenable as its value. It adopts it: it calls the thenable's then and takes the result that comes back. To do that safely, JavaScript queues a separate job called PromiseResolveThenableJob.
const order = makeOrder();const kitchenToken = { then(resolve) { resolve("burger"); },}; resolveOrder(order, kitchenToken);runJobs();Line 1 makes a pending order. Lines 2 to 6 make a plain object, kitchenToken, with a then method. That makes it a thenable. Its then calls resolve("burger") on line 4.
Line 8 resolves the order with kitchenToken. The model does not call then right here. It queues a PromiseResolveThenableJob. Line 9 runs the jobs. Only then does the order get "burger".
kitchenToken.then straight away. It adds one extra job to the jobs line. Line 9 runs that job, and only then does the order get "burger".Replay the thenable model. The extra job explains why a promise-like value can make a handler run later.
script
const kitchenToken = { then(resolve) { resolve("burger"); },}; resolveOrder(order, kitchenToken);runJobs();Why a separate job? A thenable can throw an error, call resolve twice, or come from code you don't control. The rules for adopting a thenable deal with each of those cases. The model leaves those rules out. It keeps the main point: taking a thenable's result costs one extra job.
Real promises have a then method too, so they follow the same rules. That is why a library can hand you a promise-like object, and you can still call then on it. The cost is a few more jobs than for a plain string.
When your output comes in a surprising order, first ask: what did I resolve with?
- A plain value, like a string.
- A real promise.
- Some other object with a
thenmethod.
The answer often explains why a handler ran a little later.
Why a promise inside a promise arrives later
Resolving with a plain value and resolving with another promise look alike when you write them. Inside, they are different. With another promise, the outer promise must first follow the inner one. That takes extra jobs before the outer handler can run.
A tick here means one turn of the job line: one waiting job runs. To make the ticks visible, the next code adds two marker jobs. They are tiny promise jobs that only print tick 1 and tick 2.
const later = Promise.resolve("tea");const valueOrder = new Promise((resolve) => resolve("value"));const promiseOrder = new Promise((resolve) => resolve(later)); valueOrder.then(() => console.log("value arrives"));promiseOrder.then(() => console.log("promise arrives")); for (let tick = 1; tick <= 2; tick += 1) { Promise.resolve().then(() => console.log("tick", tick));}- Line 1 makes a promise called
laterthat already holds"tea". - Line 2 makes
valueOrderand resolves it with the plain string"value". - Line 3 makes
promiseOrderand resolves it with the promiselater. - Lines 5 and 6 add a handler to each order.
- Lines 8 to 10 add the two marker jobs.
later.The output is value arrives, tick 1, tick 2, promise arrives. valueOrder was fulfilled during line 2, so its handler was ready first. promiseOrder had to follow later first. That took two extra jobs, so both markers ran before it.
Read this as an order, not a time. The markers make one fact easy to see in this exact code. The lesson test checks this printed order. It does not promise the same number of ticks in every engine or every app.
Both results still come later than the code running now. The only difference is how many jobs run first.
To check your own code, add marker jobs like these and compare the whole output. Markers are easier to reason about than milliseconds, which change when the computer is busy.
The table below lists the four kinds of promise work in this lesson. Read each row as: what it is, when it shows up, and what it does.
| What it is | When it shows up | What it does |
|---|---|---|
| A reaction record (a saved handler) | When then is called on a pending promise. | It holds your function and the new promise that gets its result. |
| A PromiseReactionJob | When a saved handler is ready to run. | It runs the handler from the job line, after the code running now. |
| A PromiseResolveThenableJob | When a promise is resolved with a thenable. | It calls the thenable's then safely, so the promise can take its result. |
| A host rejection report | When a rejected promise still has no handler after the waiting jobs. | The browser fires an event on window, or Node fires one on process. |
Reports for rejections nobody handles
A rejected promise needs a handler, such as catch, that decides what the failure means. Sometimes a promise is rejected, and it still has no handler after the waiting jobs have run. Then the host notices. The host is the program that runs your JavaScript: a browser or Node. This is called unhandled rejection tracking.
The JavaScript language says how promises behave. The host decides how to tell you about a rejection that nobody handled:
- A browser fires an
unhandledrejectionevent onwindow. - Node fires an
unhandledRejectionevent onprocess.
window.addEventListener("unhandledrejection", (event) => { console.log("unhandled", event.reason); event.preventDefault();}); const order = Promise.reject("sold out");queueMicrotask(() => order.catch(() => console.log("handled later")));- Lines 1 to 4 add a listener for the browser's
unhandledrejectionevent. On line 3,event.preventDefault()stops the browser from printing its own error. - Line 6 makes an order that is already rejected with
"sold out". - Line 7 adds a
catchhandler a moment later.queueMicrotask(fn)putsfnin the job line, so the handler is added by a job.
The handler arrives before the browser checks for unhandled rejections. So the code shows the listener and a late handler, without leaving a real unhandled error in the editor.
process.on("unhandledRejection", (reason) => { console.log("unhandled", reason);}); Promise.reject("sold out");setTimeout(() => console.log("done"), 0);Line 1 adds a listener for Node's unhandledRejection event. Line 5 makes a promise rejected with "sold out" and never handles it. Line 6 sets a timer that prints done.
The lesson test runs this code in a separate Node program. It prints unhandled sold out, then done. In real code, add the handler yourself. Don't count on the host's report.
A browser can also fire rejectionhandled. That happens when a promise was already reported, and a handler is added later. The event helps you find bugs. It is not a reason to add handlers late. A request should have a clear plan for failure before the user sees its result.
Node and browsers show these reports in different ways, and Node has settings that change them. So the lesson only checks the listener's output. The rule you control is simpler: return the promise to code that handles it, or add the handler yourself.
Try three kinds of input
The playground resolves a new promise with the input you pick. Pick one at a time and run it:
- Plain value: the string
"burger". No extra jobs are needed. - Real promise: a promise made by
Promise.resolvethat holds the burger. The outer promise must follow it. - Thenable object: an object with a
thenmethod. The outer promise must follow it too.
The playground runs real promises. It prints the two marker jobs and the moment the food arrives, in the order they happen.
const received = selected === "value" ? "burger" : selected === "promise" ? Promise.resolve("burger") : { then(resolve) { resolve("burger"); } }; new Promise((resolve) => resolve(received)) .then((food) => console.log("food", food));Promise.resolve().then(() => console.log("tick 1"));Promise.resolve().then(() => console.log("tick 2"));Plain valuePick an input, then press Run promises.
Plain value: press Run promises to see when the food arrives. This output comes from real promise jobs. Reset goes back to Plain value.
Reset picks Plain value again and clears the output. So every run starts fresh, and an earlier run can't leave jobs or text behind.
What this changes in your code
Most app code never rebuilds these rules. The useful habit is to know who owns each promise. The owner is the code that decides what happens when the promise fails. A function can do one of two things:
- return the promise, so the code that called it can handle the failure;
- add a
catchhandler itself, at a place that can show a message, try again, or recover.
function showOrder(order) { return order.then((food) => console.log("show", food));} showOrder(Promise.resolve("burger")).catch((reason) => console.log("problem", reason));Line 1 starts showOrder, which decides how to show the food. Line 2 returns the promise chain, so whoever called showOrder can still handle a failure. Line 5 calls showOrder and adds catch. That catch is the owner of any failure. Here nothing fails, so the code prints show burger.
Returning promises is usually clearer than starting a chain and forgetting where failures should go.
- A
thenhandler added to a pending order - A
thenhandler added after the order is fulfilled - Two saved handlers after
fulfill("burger") - Taking the result of
{ then(resolve) { resolve("burger"); } } - A rejected promise in a browser still has no handler
- A rejected promise in Node is never handled
Put each card in the group it belongs to. Read the explanation after each one.
Common misconceptions
- "
resolveruns the handlers right away." It only starts the process. The handlers run later, as jobs. - "
thenon a settled promise runs at once." The new handler still waits in the job line. - "Every thenable is a real promise." A thenable only needs a
thenmethod you can call. - "The unhandled report handles the error." It only tells you that your code did not handle it in time.
- "Extra ticks mean a timer." The extra turns in this lesson are promise jobs, not timers.
Each row of the table takes one word from this lesson. Read it as: what the word really means, and what it does not mean.
| The word | What it means | What it does not mean |
|---|---|---|
resolve | It starts giving the promise its result. | It does not call every then handler right away. |
| A settled promise | It is already fulfilled or rejected. | Its new handlers do not run at once; they still wait in the job line. |
| A thenable | Any object or function with a then method you can call. | It is not always a real Promise. |
| An unhandled report | A message from the host when a rejection still has no handler. | It does not replace adding .catch in your code. |
When the output order surprises you, first write down the lines that run straight away. Then list the promise jobs, in the order they were made. This short list usually explains the order. Don't guess that the browser, the network, or a timer changed it.
It also keeps the words clear:
- A reaction is a saved handler.
- A job is work waiting in line to run.
- A host report is a message from the browser or Node. It is not the promise's value or reason.
If you call all three "callbacks", you can't tell which step moved your output.
Practice exercises
Type the first word this code prints.
const order = Promise.resolve("burger");
order.then((food) => console.log(food));
console.log("waiting");It prints waiting first. The handler that prints burger waits in the job line until line 3 has run.
Who gets the tea first?
const order = Promise.resolve("tea");
order.then((food) => console.log("Asha", food));
order.then((food) => console.log("Noah", food));
console.log("waiting");Asha gets the tea first, because her handler was added first, on line 2.
A checkout library gives you a thenable, not a real promise. You resolve a promise with it. What is the name of the job that takes the thenable's result?
The job is PromiseResolveThenableJob. It calls the thenable's then, and then the outer promise takes the result.
This order is rejected with "sold out". Which promise method handles it?
const order = Promise.reject("sold out");
order.catch((reason) => console.log("handled", reason));order.catch((reason) => console.log("handled", reason));catch handles the failure, and the code prints handled sold out. Put catch where your code can decide what the failure means.
A real order screen calls this function. Where should it handle a failed request?
function showOrder(order) {
order.then((food) => console.log("show", food));
}
showOrder(Promise.resolve("burger"));Use catch, in the screen or in the code that calls showOrder. It can show a message, a retry button, or a fallback value. For the caller to add it, showOrder should also return its chain.
In this lesson's tick code, how many marker jobs run before the promise case arrives?
Two. In this lesson's tick code, both marker jobs run before the promise case arrives.
Check your understanding
Use the food-order examples. For each answer, ask: is this a saved handler, a job waiting in line, an extra job for a thenable, or a report from the host?
Question 1 of 7Where does a pending promise keep a
thenhandler?Choose an answer to see the explanation.
Question 2 of 7What does this code print?
Read the code, then predictconst order = Promise.resolve("burger"); order.then((food) => console.log(food)); console.log("waiting");Choose an answer to see the explanation.
Question 3 of 7Which job runs a saved handler once it is ready?
Choose an answer to see the explanation.
Question 4 of 7Why can resolving with a thenable take more turns?
Choose an answer to see the explanation.
Question 5 of 7In the lesson's tick code, when does the order resolved with a promise arrive?
Choose an answer to see the explanation.
Question 6 of 7What can a browser fire for a rejection that has no handler?
Choose an answer to see the explanation.
Question 7 of 7What should your app code do with a request promise?
Choose an answer to see the explanation.
Key takeaways
- A pending promise saves the handlers from
thenandcatchon a waiting list. - When it settles, each saved handler becomes a job, called a PromiseReactionJob. So handlers run after the code running now.
- Taking a thenable's result needs one more job, called a PromiseResolveThenableJob. That can add extra ticks.
- Resolving with another promise can arrive later than resolving with a plain value.
- Browsers and Node report rejected promises that nobody handled, through events.
- Your code still needs a clear owner for every failure.
Remember the one-liner.
A promise saves handlers now and runs them later as jobs.
Coming next: await under the hood. You will see how an async function pauses and starts again, using these same promise jobs.