The total that won't add up
nodeSoft~12 min

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:

How to spot it in the wild:

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.

Back to the crumb