Debug in VS Code with breakpoints instead of console.log

How to debug in VS Code with breakpoints, conditional breakpoints, Watch, the Debug Console and logpoints, through a bug that turned 70,000 đồng into 70.

Why debug in VS Code with breakpoints instead of console.log? A breakpoint stops the program on one exact line and shows you the real value and the real type of every variable. console.log only prints what you guessed you'd need, and it can mislead you. We'll chase down one wrong number both ways.

An order that costs 70 đồng

In the previous post, you read a stack trace to fix the crashing orders page of Mộc, a small coffee shop. The page works again, but one line in its output is very strange: order 1044, from Lê Chi, is two salt coffees, and the total is 70 đồng.

This time there's no red text. The page returns status 200, there's no stack trace, nothing to point the way. The program runs smoothly and produces a wrong number.

Rebuild the order system on your machine

If you haven't read the previous post, that's fine, because the system is only three files. Mộc is made up for this series, but the code really runs, on Node.js 22.18 or later, the first Node release that runs TypeScript files directly without installing anything else. Create this folder and open it in VS Code:

Folder structure
moc-orders/
  data/orders.json   # the list of orders
  src/orders.ts      # order helpers: customer name, total, the list
  src/server.ts      # server that returns the order list at http://localhost:3000/orders

The first file holds the order helpers. Keep an eye on orderTotal on lines 17 to 19: it adds up the price of an order, and this whole post revolves around it:

src/orders.ts
export type Customer = { name: string; phone: string };
export type OrderItem = { name: string; price: number; quantity: number };
export type Order = {
  id: number;
  createdAt: string;
  source: "web" | "old-pos";
  // Walk-in orders from the old point-of-sale machine (source "old-pos") may have no customer.
  customer?: Customer;
  items: OrderItem[];
};

export function customerLabel(order: Order): string {
  if (!order.customer) return "WALK-IN CUSTOMER";
  return order.customer.name.toUpperCase();
}

export function orderTotal(order: Order): number {
  return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

export function listOrders(orders: Order[]) {
  return orders.map((order) => ({
    id: order.id,
    customer: customerLabel(order),
    total: orderTotal(order),
  }));
}

The second file is the server. It reads the JSON file once at startup, and whenever someone opens /orders it returns the id, customer name and total of every order:

src/server.ts
import { readFileSync } from "node:fs";
import { createServer } from "node:http";

import { listOrders, type Order } from "./orders.ts";

const dataFile = new URL("../data/orders.json", import.meta.url);
const orders: Order[] = JSON.parse(readFileSync(dataFile, "utf8"));

export const server = createServer((req, res) => {
  if (req.url === "/orders") {
    try {
      const body = JSON.stringify(listOrders(orders), null, 2);
      res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(body);
    } catch (error) {
      console.error(error);
      res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Server error");
    }
    return;
  }
  res.writeHead(404).end();
});

const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`Moc orders: http://localhost:${port}/orders`));

The last file is the data: four orders from one morning, with prices in Vietnamese đồng. The first two came in through the website, the other two were entered on the old point-of-sale machine at the counter. Copy the whole file, but try not to study it closely yet. Pretend it holds five thousand orders and you can't read them all by eye:

data/orders.json
[
  {
    "id": 1041,
    "createdAt": "2026-11-03T08:12:00+07:00",
    "source": "web",
    "customer": { "name": "Nguyễn An", "phone": "0901000001" },
    "items": [
      { "name": "Iced milk coffee", "price": 29000, "quantity": 2 },
      { "name": "Egg banh mi", "price": 25000, "quantity": 1 }
    ]
  },
  {
    "id": 1042,
    "createdAt": "2026-11-03T09:40:00+07:00",
    "source": "web",
    "customer": { "name": "Trần Bình", "phone": "0901000002" },
    "items": [{ "name": "Bac xiu", "price": 32000, "quantity": 1 }]
  },
  {
    "id": 1043,
    "createdAt": "2026-11-03T10:05:00+07:00",
    "source": "old-pos",
    "items": [{ "name": "Black coffee", "price": 25000, "quantity": 1 }]
  },
  {
    "id": 1044,
    "createdAt": "2026-11-03T10:30:00+07:00",
    "source": "old-pos",
    "customer": { "name": "Lê Chi", "phone": "0901000004" },
    "items": [{ "name": "Salt coffee", "price": "35.000", "quantity": 2 }]
  }
]

Start the server with node src/server.ts, then open a second terminal and run curl http://localhost:3000/orders. The result for order 1044 is:

curl http://localhost:3000/orders (last order)
{
  "id": 1044,
  "customer": "LÊ CHI",
  "total": 70
}

On the shop's menu a salt coffee costs 35,000 đồng (a little over one US dollar), so two should be 70,000. orderTotal just multiplies price by quantity and adds it up. There's nothing there to get wrong, and yet it's wrong.

Try console.log first

The first reflex for most of us is to drop a console.log into the middle of the function. I did the same, so you can see where it leads. Replace orderTotal in src/orders.ts with the version below. It calculates the same thing, with one extra line that prints the order id, item name, price and quantity of each item before adding it:

src/orders.ts — with a log
export function orderTotal(order: Order): number {
  return order.items.reduce((sum, item) => {
    console.log(order.id, item.name, item.price, item.quantity);
    return sum + item.price * item.quantity;
  }, 0);
}

Stop the server (Ctrl+C), run node src/server.ts again, call curl as before, and the server's terminal prints one line per item:

Terminal
Moc orders: http://localhost:3000/orders
1041 Iced milk coffee 29000 2
1041 Egg banh mi 25000 1
1042 Bac xiu 32000 1
1043 Black coffee 25000 1
1044 Salt coffee 35.000 2

Look at the last line. Do you see anything odd? The first time I looked, I didn't. Price 35.000, quantity 2, which looks right for a salt coffee. In Vietnam, as in Germany and much of Europe, a dot separates thousands, so to the shop's staff 35.000 simply means thirty-five thousand.

That's the trap of console.log. When it prints several values at once, Node.js prints strings just like numbers, without quotes. The number 35000 and the string "35.000" come out nearly identical, apart from a dot your eyes may read as a thousands separator, or skip as a typo. In a terminal with colours, Node usually prints numbers in a different colour from strings, but you need sharp eyes to notice.

On top of that, this log prints all five items of all four orders, while I only care about one order. With a real file of a few thousand orders, the line you need would drown among thousands of others. And once you find the bug, you still have to remember to delete the log line, or it quietly ends up in a commit.

I deleted the log line, put the code back the way it was, and tried something else.

Set your first breakpoint

A breakpoint is a stopping point. When the program reaches a line with a breakpoint, it pauses, and you get to look inside: which variable holds which value, which function called this one, where the data came from.

In VS Code, you set a breakpoint by clicking the empty space to the left of a line number. A red dot appears. I put it on line 18 of src/orders.ts, the return order.items.reduce(...) line inside orderTotal. If you added the console.log above, put the function back to the original first, so the line numbers match this post.

Next, the program has to run under the debugger. The simplest way, and the one I use most, is the JavaScript Debug Terminal. Open the Command Palette with Ctrl+Shift+P, type "Debug: Create JavaScript Debug Terminal" and select it. VS Code opens a new terminal, and according to VS Code's Node.js debugging documentation, every Node.js process started in this terminal is debugged automatically. Start the server as usual:

JavaScript Debug Terminal
node src/server.ts

Then open another, regular terminal and call the page:

Second terminal
curl http://localhost:3000/orders

curl just sits there and returns nothing. That's a good sign. The program has stopped on line 18, and in VS Code that line is highlighted in yellow.

If you'd rather press F5, create a .vscode/launch.json file like this:

.vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run the orders server",
      "program": "${workspaceFolder}/src/server.ts",
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

The skipFiles line tells the debugger to skip Node.js's internal code when you step through. Without it, a few presses of "step into" land you in node:events and node:_http_server, the very lines you learned to cross out when reading a stack trace.

Stop exactly at order 1044 with a conditional breakpoint

There's a small problem. orderTotal is called for all four orders, in the order 1041, 1042, 1043, 1044. A plain breakpoint stops at order 1041 first, and you have to press Continue (F5) three times to reach the one you want. With four orders that's bearable. With four thousand it isn't.

Right-click the red dot, choose Edit Breakpoint, pick the Expression type, and type the condition:

Breakpoint condition
order.id === 1044

The red dot changes shape to show it's a conditional breakpoint. From now on the program only stops when the condition is true. The Debug code with Visual Studio Code guide lists two more kinds: stopping after a number of hits (hit count), and only enabling a breakpoint after another one has stopped. The expression kind is the one I use most, because bugs usually only happen with one particular record.

Read values in Variables, Watch and the Debug Console

When the program stops at order 1044, open the Run and Debug view on the left. The VARIABLES section lists the variables of the function that's paused. Expand order, then items, then the first element, and you see:

VARIABLES (paused at order 1044)
order
  id: 1044
  source: 'old-pos'
  items: Array(1)
    0:
      name: 'Salt coffee'
      price: '35.000'
      quantity: 2

This time price has quotes and quantity doesn't. The price is a string, not a number. What console.log hid, the debugger shows at first glance. If you want to be sure, add an expression to the WATCH section, which re-evaluates it every time the program stops:

WATCH
typeof order.items[0].price: 'string'

The next question is how a string turns into 70. Open the Debug Console with Ctrl+Shift+Y. Here you type JavaScript expressions and they're evaluated right in the context where the program is paused. I tried a few calculations, and the results below match what I got when I ran them again with Node.js:

Debug Console
> order.items[0].price * order.items[0].quantity
70
> Number("35.000")
35
> "35.000" + 0
'35.0000'

Now everything is clear. For multiplication, JavaScript converts the string to a number. But it reads the string the English way, where the dot is a decimal point, so "35.000" becomes 35, not thirty-five thousand. Times 2 makes 70.

The third line is a related trap: with +, JavaScript does the opposite and turns the number into a string, then joins the two. MDN explains this automatic conversion under type coercion.

One question remains: where did the string come from? The CALL STACK section shows who called orderTotal. Click the listOrders frame, then the frame in server.ts, and you see that orders was created by JSON.parse from data/orders.json. Open that file, and order 1044 has "source": "old-pos" and "price": "35.000". The shop's old POS machine writes prices as text with dots between the thousands, while the website writes them as numbers.

So you found the cause without changing a single line of code, and there's no log line to remember to delete.

Logpoints: logging without touching the code

Sometimes you don't want the program to stop, for example when the server is handling lots of requests, or when you just want to follow one value across many runs. That's what logpoints are for. Right-click the left margin, choose Add Logpoint, and type a message with expressions in curly braces:

Logpoint message on line 18
order {order.id}: first price = {JSON.stringify(order.items[0].price)}

A logpoint prints to the Debug Console every time that line runs, but it doesn't stop the program and it isn't in your code file. Stop the debugger and it's gone, so there's no way to commit it by accident.

Notice that I wrapped the value in JSON.stringify. That's a trick learned from this very bug: JSON.stringify always prints strings with double quotes, so "35.000" and 35000 can never look alike again. It works with plain console.log too. If you have to log, log { price } or JSON.stringify(price) instead of a bare price.

Where should the fix go?

Now that you know the cause, the quickest fix everyone thinks of is forcing the price to a number right inside orderTotal with Number():

src/orders.ts — the hasty fix (don't do this)
export function orderTotal(order: Order): number {
  return order.items.reduce((sum, item) => sum + Number(item.price) * item.quantity, 0);
}

I tried it on order 1044. Number() gives a total of 70. Switching to parseInt() also gives 70. Both read "35.000" as 35, exactly as the Debug Console just showed. A fix like this doesn't remove the bug, and it makes the next person reading the code believe it's been handled.

The lesson from the previous post still holds: don't patch where the error shows up, fix where the bad data comes in. Data enters the system in exactly one place, when server.ts reads the JSON file. I added a normalization step right there, in two parts.

The first part is a type and two functions in src/orders.ts, placed right below the Order type. parsePrice turns any price from the file into a number of đồng. normalizeOrder runs parsePrice on every item of an order. orderTotal stays as the original, with no Number():

src/orders.ts — add right below the Order type
// Data as read from the file, not checked yet. The old POS writes prices as text with dots between thousands ("35.000").
export type RawOrder = Omit<Order, "items"> & {
  items: { name: string; price: number | string; quantity: number }[];
};

// "35.000" → 35000. Anything else unusual throws right away instead of guessing.
export function parsePrice(raw: number | string): number {
  if (typeof raw === "number") return raw;
  const text = raw.trim();
  if (/^\d{1,3}(\.\d{3})+$/.test(text)) return Number(text.replaceAll(".", ""));
  if (/^\d+$/.test(text)) return Number(text);
  throw new Error(`Invalid price: "${raw}"`);
}

export function normalizeOrder(raw: RawOrder): Order {
  return {
    ...raw,
    items: raw.items.map((item) => ({ ...item, price: parsePrice(item.price) })),
  };
}

The second part makes server.ts call normalizeOrder right after reading the file. Only the import line and the two lines that read the data change. Everything else stays the same:

src/server.ts — after the fix
import { readFileSync } from "node:fs";
import { createServer } from "node:http";

import { listOrders, normalizeOrder, type RawOrder } from "./orders.ts";

const dataFile = new URL("../data/orders.json", import.meta.url);
// Normalize as soon as the file is read: from here on, prices are always numbers.
const rawOrders: RawOrder[] = JSON.parse(readFileSync(dataFile, "utf8"));
const orders = rawOrders.map(normalizeOrder);

export const server = createServer((req, res) => {
  if (req.url === "/orders") {
    try {
      const body = JSON.stringify(listOrders(orders), null, 2);
      res.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(body);
    } catch (error) {
      console.error(error);
      res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Server error");
    }
    return;
  }
  res.writeHead(404).end();
});

const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`Moc orders: http://localhost:${port}/orders`));

The RawOrder type tells the truth about the data in the file: a price can be a number or a string. The Order type describes the data after it has been checked: the price is always a number. Every function after that point, including orderTotal, only works with Order, so none of them has to worry about strings anymore. It's the same idea as the ? in the previous post: let the types describe reality, then let TypeScript stand guard for you.

The part most worth discussing is the throw line. To see how the function handles each kind of price, I wrote a small test file that calls parsePrice with a few values and prints either the result or the error message:

src/try-prices.ts
import { parsePrice } from "./orders.ts";

for (const value of ["35.000", 35000, "35000", "1.250.000", "35,000", "35.5", "abc"]) {
  try {
    console.log(JSON.stringify(value), "→", parsePrice(value));
  } catch (error) {
    console.log(JSON.stringify(value), "→", (error as Error).message);
  }
}
node src/try-prices.ts
"35.000" → 35000
35000 → 35000
"35000" → 35000
"1.250.000" → 1250000
"35,000" → Invalid price: "35,000"
"35.5" → Invalid price: "35.5"
"abc" → Invalid price: "abc"

Why is "35,000" rejected? Because I don't know whether the old machine ever writes prices that way, and if it did, whether it would mean thirty-five thousand (the American way) or thirty-five point zero (the way much of Europe writes it). Guess wrong and you get another silently wrong total, exactly the kind of bug that just cost me a whole morning. An error that shouts the moment the data is read is much easier to fix than a wrong number sitting quietly in the end-of-month revenue report.

Run the server again with the new code:

curl http://localhost:3000/orders (order 1044)
{
  "id": 1044,
  "customer": "LÊ CHI",
  "total": 70000
}

Two salt coffees, 70,000 đồng. The other three orders keep the same totals as before.

When does console.log still make sense?

I don't think you should give up console.log entirely. For a small script you run once, printing a value is the fastest thing to do. For timing bugs, such as two requests running in parallel and racing to write data, pausing at a breakpoint can change the order things run in and make the bug disappear. Then logs, or logpoints, show you the real order. And on a production server, where you can't attach a debugger, logs are all you have; later posts in this series cover how to write logs that actually help.

But for the question "what is this value, really, while the program runs?", a breakpoint is almost always faster. You see the type, the whole object, who called the function, and you can ask anything else in the Debug Console without running the program again. If you've tried breakpoints and you're still stuck, take a screenshot of the Variables and Call Stack sections when you ask someone for help, so they can see what you saw.

It's fixed. How do you keep it from coming back?

You tell the owner that order 1044 is right now. The owner asks a very fair question: "What if the old machine sends data like that again? What if someone changes the code and breaks it again?"

Right now, the only proof that the bug is fixed is one curl call you checked by eye. Nothing stops someone else, or you a few months from now, from deleting the normalization step because it looks unnecessary. You need something that automatically checks parsePrice and the total of order 1044 every time the code changes. That's a test, and the next post in the series writes a first test with Vitest to lock this bug down. You can follow the whole story in the series Taking over a legacy project, and interview questions about breakpoints and console.log are in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.