In a world with , chapter 4.2.
THE PATH · SESSION 18 OF 66 · UNIT 4 · ACT I
The Waiting Room
Promises, await, and the three ways a request ends
At a glance
- PACE
- Session 18 of 66 · suggested week 6. Continue when you are ready.
- UNIT
- Unit 4 · I Built a Party of Four: Mongo, Express, React, Node
- ACT
- Act I · Foundation
- BEFORE
- 4.1 The Runtime Beyond the Browser
- AFTER
- 4.3 The Listening Door
- ARTIFACT
- A client.js in guild-door that calls the server with fetch and prints one answer, one refusal, and one silence, with the three transcripts kept in NOTES.md
- READ
- 50 min · 3 diagrams · 10 code blocks
The hook#
Last session you started a process that listens on port 3000 and answers GET / with a sentence. You knocked on it with curl. Curl is a separate program, so the waiting happened outside your code. Today the knock comes from JavaScript. A second file in the same folder, client.js, asks the server for / with fetch, and between the asking and the answer there is a gap your program has to live through. The thing that fills that gap is a promise. The keyword that lets you wait on it without your whole program going blind is await.
By the end of the session you can watch one request end three different ways, in order, in your own terminal. The server answers 200 and you print the body. The server answers 404 and you print that it said no. The server is stopped and fetch throws, and you catch it and print that nothing came back. Those are not three bugs. They are the three endings every request you write for the rest of this unit can have, and one short pattern handles all three. The database calls in the vault, the routes in the listening door, and the form in the quest slate all reuse it.
You hand the courier a letter and the courier leaves. You hold a ticket with nothing written on it yet. The ticket is the promise.
The idea#
Node runs your JavaScript on one thread. You met that sentence last session. A network request takes milliseconds, sometimes seconds, and your thread cannot stand still for it, so the request is handed to the operating system and your function keeps going. The question is what your function holds in its hand while the answer is in transit. The answer is a promise. The Promise reference calls it an object that represents the eventual completion or failure of an asynchronous operation and its resulting value. Read that as a receipt. The work is not done. The receipt is real now, and it will be stamped later.
A promise is in one of three states. It starts pending. Later it settles, and settling goes one of two ways: fulfilled with a value, or rejected with a reason. The reason is usually an Error. A promise settles once. It cannot be fulfilled and then rejected, and it cannot be fulfilled twice. Once it has settled, anyone who asks it later gets the same answer. This is the part people skip, and it is the part that makes the rest make sense. You are not polling. You are not re-running the request. You are holding one object that will change state exactly one time.
fetch(url) returns a promise immediately. Nothing has gone over the network when that line finishes. If you print the return value you see Promise { <pending> }, and that print is correct, not broken. The promise is fulfilled when the server's status line and headers have arrived. It is fulfilled with a Response object. It is rejected only when no response could be obtained at all: the connection was refused, the host name did not resolve, the network dropped. Using Fetch says this in one sentence that is worth copying by hand: the promise does not reject on HTTP errors. A 404 is an HTTP error. It is a complete, well-formed answer whose status happens to be 404. The promise is fulfilled. Whether the server said yes is a second question, and the Response answers it through response.ok, which is true for any status from 200 through 299 and false otherwise.
await, and the function it lives in#
await is how you read the receipt without writing a callback. The await reference describes it as pausing the function until the promise settles. Say it the way you would say it to a friend: wait here, then continue. If the promise is fulfilled, await gives you the value, and the next line runs with it. If the promise is rejected, await throws the reason at that line, as if you had written throw yourself. That is why try and catch work on an awaited promise. The catch is catching a throw that await performed on your behalf.
The pause is local. While your function is paused at an await, the thread is free. Other callbacks run. A server keeps answering other clients. When the promise settles, your function is queued to continue from the line after the await, with the value in hand. The event loop from last session is doing the delivery. You did not write a callback, but a callback is what the runtime registered for you.
You can only write await inside a function marked async. The async function reference gives the other half of the contract: an async function always returns a promise. return 'saved' from an async function hands the caller a promise that is fulfilled with 'saved'. A throw inside it, or a rejected await you did not catch, hands the caller a promise that is rejected. The caller then has the same choice you had: await it inside another async function, attach .then and .catch, or ignore it. Ignoring a promise that rejects is the crash you will meet in Where it breaks. A promise is not an exception you can leave on the floor.
.then is the older spelling of the same wait. fetch(url).then(function (response) { ... }) registers a function to run when the promise fulfills. .catch(function (err) { ... }) registers one for the rejection. You will see that spelling in the React session's effect, because an effect's body cannot be async. The meaning is identical. await is .then with the continuation written on the next line instead of inside a function. Pick await wherever you are allowed to, because the try/catch reads like the rest of your code.
Two failures that look the same from the chair#
Here is the distinction the rest of the unit depends on, and it is the one most tutorials blur. When a request goes wrong, there are two completely different shapes.
The first shape: a response arrived, and it says no. The status is 404 because the path does not exist, or 400 because the body was wrong, or 500 because the server threw. The promise from fetch is fulfilled. await fetch(url) returns normally. Your catch does not run, because nothing was thrown. The signal is response.ok === false. You check it with an if. The server is alive, reachable, and disagreeing with you. That is useful information, and it comes with a status code that tells you which kind of disagreement.
The second shape: no response at all. The server process is stopped, or you typed the port wrong, or the machine is off the network. There is no status, because there is no status line. The promise from fetch is rejected, with a TypeError whose message in Node is fetch failed. await fetch(url) throws. Your catch runs. response is still undefined, because the assignment on the right of await never completed. If you reach for response.ok here you get a second error on top of the first.
The two shapes need two different tools. try/catch handles the second. An if on response.ok handles the first. A try/catch around the whole thing does not make the if unnecessary, and the if does not make the try unnecessary. The handling that covers both looks like this, and you will write it by hand before the session ends.
// fragmentlet response;try { response = await fetch(url);} catch (err) { // no response: the server is stopped or the address is wrong return 'not saved (no response)';}if (!response.ok) { // a response arrived and said no return 'not saved (' + response.status + ')';}return 'saved';The return inside the catch is not decoration. It is what keeps the if from reading .ok off undefined. If you prefer not to return early, set response to a stand-in object inside the catch instead. Either way, the line after the catch must be safe to run whether or not the catch ran.
What you should be able to draw on a napkin: fetch returns a promise at once, in the pending state. The promise settles once. Fulfilled means a Response arrived, and response.ok says whether it was a yes. Rejected means nothing arrived, and await throws it into your catch. await only lives inside async, and an async function hands its caller a promise of its own.

Why this comes before the router#
Next session every route handler you write is async, and Express forwards a rejected handler to the error handler for you. That sentence is only useful if you can see the promise it is talking about. The session after that, await client.connect() is the first line that can hang your startup. Later, a React form waits for a 2xx before it tells the person the row is saved. All of those are the same pattern with different nouns: one try, one if, one return. Today the noun is a request to your own door, and the server is a thing you can stop with Ctrl-C so the third ending is one keystroke away. You will never again have a failure this easy to produce on purpose, so produce it on purpose now.
The demonstration#
One folder, the one from last session, ~/projects/guild-door. server.js is unchanged. You add one file beside it. Node 24 ships fetch as a global, documented on the Node globals page, so there is nothing to install.
Write client.js:
const url = process.argv[2] || 'http://localhost:3000/'; async function load(url) { console.log('pending: ' + url); let response; try { response = await fetch(url); } catch (err) { console.log('no response: ' + err.message); return 'no response'; } if (!response.ok) { console.log('said no: ' + response.status); return 'said no (' + response.status + ')'; } const body = await response.text(); console.log('answered: ' + response.status + ' ' + body); return 'ok';} load(url).then(function (result) { console.log('result: ' + result);});Read it top to bottom before you run it. process.argv[2] is the first word after the file name on the command line, so you can point the client at a different path without editing the file. load is async, which is what lets the await on line seven exist. The first console.log runs before anything leaves the machine. That line is your pending marker. The try wraps only the await fetch, because that is the only line that can throw for the no-response case. response.text() is a second promise, because the body can arrive after the headers, so it has its own await. The last three lines are the caller. load returns a promise, and .then is how a plain script, which is not an async function, reads it.
Three runs. Write your prediction down before each one, in NOTES.md in the same folder, as one line. Then run, and put the real lines under the prediction.
Run one: the server answers. Prediction first. Which line prints first, pending or answered? What status do you expect, and what body? Start the server in one terminal:
cd ~/projects/guild-doornode server.jsIt prints listening on 3000. In a second terminal:
cd ~/projects/guild-doornode client.jsYou see three lines, in this order:
pending: http://localhost:3000/answered: 200 The door is open.result: okpending printed first because it ran before the await. answered printed when the promise fulfilled and the body promise fulfilled after it. result: ok printed last, from the .then, after load returned. If your GET / sends your name and Node version from last session's quest, the body is that string instead of the sentence. Fine. The status is still 200.
Run two: the server says no. Prediction: the server is still running. You ask for a path it does not have. Does catch run? What is response.ok? Which line prints, said no or no response? Leave the server running and, in the second terminal:
node client.js http://localhost:3000/nopepending: http://localhost:3000/nopesaid no: 404result: said no (404)The promise was fulfilled. The catch did not run. The Response has status 404 and ok is false, so the if took over. Look at the server's terminal. Express logged nothing, because last session's server has no logger yet, but it did answer: 404 is Express's built-in reply when no route matches. You can confirm with curl -i http://localhost:3000/nope and read the status line. The server is alive and disagreeing with you.
Run three: no server. Prediction: you stop the server. Does pending still print? Does said no print? What does err.message say? In the first terminal, press Ctrl-C. The prompt comes back. In the second terminal:
node client.jspending: http://localhost:3000/no response: fetch failedresult: no responsepending still printed, because the request had not been attempted yet. Then fetch tried to connect, the operating system refused, and the promise rejected with a TypeError whose message is fetch failed. await threw it. The catch ran. The if never ran, because the function returned from inside the catch. There is no status. There was never a status line to read. If you want the underlying reason, err.cause carries it, and its code is ECONNREFUSED, which reads as connection refused. Compare it with the curl line you wrote down in 4.1 step 5. Curl spells the same refusal as Failed to connect to localhost port 3000 ... Connection refused, or Couldn't connect to server on newer curl. Same refusal, two spellings: curl never prints the token ECONNREFUSED, and Node never prints curl's sentence.
The timeline, side by side#
| Run | What you typed | First line | Second line | Third line | How the promise settled |
|---|---|---|---|---|---|
| One | node client.js with the server up | pending: http://localhost:3000/ | answered: 200 The door is open. | result: ok | fulfilled, ok true |
| Two | node client.js http://localhost:3000/nope | pending: http://localhost:3000/nope | said no: 404 | result: said no (404) | fulfilled, ok false |
| Three | node client.js after Ctrl-C on the server | pending: http://localhost:3000/ | no response: fetch failed | result: no response | rejected, catch ran |
Three things to notice across the rows. The first line is identical in shape every time, because the pending state does not know how the story ends. The second line is the only one that differs, and it comes from two different places: the if in rows one and two, the catch in row three. The third line is always last, because .then cannot run until load has returned, and load cannot return until its await has settled. If you ever see result: above the second line, the function did not wait, and that is the first diagnosis below.
A small model you can run here#
The page cannot start your server or reach your port, so the checkpoint below replaces fetch with three fake versions: one that resolves to { ok: true, status: 200 }, one that resolves to { ok: false, status: 404 }, and one that rejects with TypeError('fetch failed'). It runs the same handling against each, with the words the form in 4.12 will put on its notice, and it checks the order of a pending and settled log.
function check(condition, message) { if (!condition) throw new Error(message); console.log('PASS:', message);} const log = []; async function load(fetchLike) { log.push('pending'); let response; try { response = await fetchLike(); } catch (err) { log.push('settled: rejected ' + err.message); return 'not saved (no response)'; } log.push('settled: fulfilled ' + response.status); if (!response.ok) { return 'not saved (' + response.status + ')'; } return 'saved';} function answers(status) { return function () { return Promise.resolve({ ok: status >= 200 && status < 300, status: status }); };} function noServer() { return Promise.reject(new TypeError('fetch failed'));} const yes = await load(answers(200));check(yes === 'saved', 'a 200 response is saved'); const no = await load(answers(404));check(no === 'not saved (404)', 'a 404 arrived, said no, and was not a rejection'); const silence = await load(noServer);check(silence === 'not saved (no response)', 'a rejected fetch is caught as no response'); check(log.length === 6, 'each call logged once pending and once settled');check(log[0] === 'pending' && log[1] === 'settled: fulfilled 200', 'pending is logged before the promise settles');check(log[3] === 'settled: fulfilled 404', 'the 404 settled as fulfilled, not rejected');check(log[5] === 'settled: rejected fetch failed', 'the no-response case settled as rejected with the TypeError message'); const held = load(answers(200));check(held instanceof Promise, 'calling an async function without await hands you a promise');check(log.length === 7 && log[6] === 'pending', 'the pending line already ran, the settled line has not');await held;check(log.length === 8, 'after await, the settled line has run'); console.log('ok: checkpoint-4-2');What it proves: the shape of the handling. One try for the rejection, one if for the refusal, one return on the far side for the yes, and the pending line always ahead of the settled line. It also proves that an async function called without await gives you a promise and not a result. What it does not prove: that your server is running, that port 3000 is the right port, or that fetch in your Node behaves the way the fake does. The three runs in your terminal are the demonstration. The model only checks that you can say what each run showed. One more thing to notice: the block uses await at its top level, outside any function. That works because the run box wraps the block in an async function before it runs it, and it would also work in a Node 24 ES module. In a plain CommonJS script like client.js the same line is the third diagnosis below, which is why client.js reads its result with .then instead.
Where it breaks#
Your turn#
The quest#

The deliverable is client.js committed in ~/projects/guild-door beside server.js, with NOTES.md in the same folder holding the evidence. NOTES.md is one page, in this order:
- The three transcripts, each one your prediction line followed by the three real lines, pasted exactly, headed
Run one: answered,Run two: said no,Run three: no response. - The pattern from The idea, the
try, theif, and thereturn, written by hand from memory, not pasted. Write it out and then compare it with the fragment in The idea. - Under the pattern, two sentences in your own words: which of the two failures the
tryis for, and which theifis for. - One line naming the three outcomes a request can have and the state of the promise in each: fulfilled with
oktrue, fulfilled withokfalse, rejected. - The exact first line of the stack trace from step seven, and the exact
SyntaxErrorline from step eight.
Commit client.js and NOTES.md. node_modules stays out, the same .gitignore as before. Push to the guild-door repository you created last session. If you kept RUNTIME.md from 4.1, leave it. NOTES.md is the ledger for this session and the next ones; 4.3 adds STACK.md beside it.
Side quests#
The promise sheet is the full set of combinators and traps behind today's one pattern, and the grit essay is for the evening you run the client a fourth time and it still says fetch failed, because the server you forgot to start is the most common bug in this unit and it is not a sign of anything.
Side quests from this session
- Async & PromisesThe Async & Promises sheet holds the combinators and traps behind the one five-line pattern this session rebuilds by hand.Languages
- Grit: The Long ReturnThe grit essay is for the fourth run that still prints fetch failed because the server was never started, the most common bug in the unit and not a verdict on you.Mindset
Before next session#
Next session replaces server.js with a stack: a logger, express.json(), a router mounted at /quests, a 404 middleware, and a four-argument error handler. One of its routes is an async function that throws on purpose, and Express forwards that rejection to the error handler for you. You now know what that sentence means. The route returns a promise because it is async. The promise rejects because the function threw. Express is the one holding the promise, so Express is the one whose catch runs. The route you write by hand with Promise.reject and no return is the unhandled rejection from today's second diagnosis, moved inside a server. Read the asynchronous section of the Express error-handling guide once before you sit down, with today's three transcripts beside it.
Stop the server with Ctrl-C if it is still up. Nothing stays running between sessions. ~/projects/guild-door now holds server.js, client.js, NOTES.md, the manifest, and the lockfile. 4.3 starts by replacing server.js in that same folder; client.js stays where it is and still works against the new stack, because GET / still answers and /nope is still a 404, and the third run is still one Ctrl-C away.
See also