Why the total comes back as nothing
What you saw
The line items load and render perfectly. The total at the bottom of the page reads $NaN. Curl the total endpoint directly and you get something stranger than an error:
{ "total": {} }
Not a number. Not null. An empty object. The items endpoint, sitting right next to it and reading the exact same data, works fine. So the data is good, the math is good, and nothing throws. The bug is entirely in how the total leaves the building.
What's actually happening
Here are the two handlers side by side. Spot the difference:
// items — works
app.get('/api/cart/:id/items', async (req, res) => {
const items = await getCartItems(req.params.id)
res.json({ items })
})
// total — broken
app.get('/api/cart/:id/total', (req, res) => {
const total = getCartTotal(req.params.id)
res.json({ total })
})
getCartTotal is an async function. The moment you call an async function, it returns a Promise — immediately, synchronously, before any of its work has run. The number you actually want is inside that Promise, delivered later when it resolves.
The items handler awaits its call, so by the time res.json runs, items is the real array. The total handler doesn't await anything, so total isn't the number 148 — it's a pending Promise object. The handler then hands that Promise straight to res.json.
Now the second half of the trap. res.json serializes with JSON.stringify, and JSON.stringify doesn't know what a Promise is. A Promise has no enumerable own properties, so it stringifies to {}:
JSON.stringify(Promise.resolve(148)) // -> '{}'
That's why the body is { "total": {} }. On the page, the client does Number(total).toFixed(2), and Number({}) is NaN — so you get $NaN. The NaN is a symptom two steps downstream; the actual mistake is one missing word in the handler.
It doesn't throw because nothing here is illegal — passing a Promise to res.json is perfectly valid JavaScript, it just doesn't do what you meant. That's what makes this one sneaky: no stack trace points at it. The response goes out 200 OK with a body that's quietly wrong.
The fix
await the async call — which means the handler itself has to be async:
app.get('/api/cart/:id/total', async (req, res) => {
const total = await getCartTotal(req.params.id)
res.json({ total })
})
Now total is 148, the body is { "total": 148 }, and the page shows $148.00. Two changes, and they come as a pair: you can't await inside a function that isn't async, so adding the await forces you to add async too.
A small bonus worth carrying: once you're awaiting, wrap it in try/catch (like the items handler effectively needs) so a rejected Promise — say, an unknown cart — becomes a clean 4xx instead of an unhandled rejection:
app.get('/api/cart/:id/total', async (req, res) => {
try {
const total = await getCartTotal(req.params.id)
res.json({ total })
} catch (err) {
res.status(404).json({ error: err.message })
}
})
The bug class: the missing await
This is the missing await bug — calling an async function and using its return value as if it were the resolved result, when it's actually still a Promise. It's one of the most common mistakes in async JavaScript, and it's epidemic in AI-generated code, because forgetting await produces code that looks completely correct and often half-works.
The reason it's so easy to miss: a forgotten await rarely crashes. The Promise is a valid value, so it flows downstream and corrupts something far from the scene:
- Serialized to JSON, it becomes
{}(what happened here). - Coerced to a number, it becomes
NaN. - Put in a template string, it becomes
"[object Promise]". - Used in a condition, it's always truthy — a pending Promise is an object, so
if (await checkAccess())andif (checkAccess())can behave oppositely, which is how missing-awaitbecomes a security bug.
How to spot it in the wild:
- You see
[object Promise],NaN,{}, orundefinedwhere a real value should be — and there's no error to explain it. - An
asyncfunction is called withoutawait(and not deliberately returned or.then-ed). Most linters will flag this as a "floating promise" or "no-floating-promises" — turn that rule on; it catches this whole class for free. - A value "is there" but is the wrong type: you expected a number and got an object.
The one-line mental model: calling an async function gives you a Promise, not a result. The result only exists after you await it. Any time you use the return value of an async function immediately, the await has to be there — and if your editor underlines await with "await has no effect" or you can't add it because the enclosing function isn't async, that's the tell that you've found one of these.