Module 7 ended with an uncomfortable diagnosis: Nómada Tasks has six layers, fourteen modules, two data sources and a real-time channel, and the only way to know whether something works is to open it and try it by hand. This lesson attacks the first half of the problem —once you know that something is broken, how do you find out why?— and it does so with a change of mindset: debugging is not guessing, it is a procedure. You are going to learn the cycle reproduce → isolate → form a hypothesis → verify → fix → prevent, the bisection technique that halves the suspect ground at every step, the whole console (which is far more than console.log), the DevTools debugger with its six kinds of breakpoint, how to read an asynchronous call stack, source maps, and the network and storage panels. And you will finish by solving three real Nómada Tasks bugs step by step, with the method in front of you.

Contents

  1. Why debugging by intuition does not scale
  2. The method: six steps
  3. Bisection: splitting the problem in half
  4. The console beyond console.log
  5. The curly-brace trick: console.log({ variable })
  6. The debugger statement and your first breakpoint
  7. The six types of breakpoint
  8. Stepping through code: step over, into and out
  9. The Scope, Watch and Call Stack panels
  10. Reading a stack trace and "Pause on exceptions"
  11. Debugging asynchronous code
  12. Source maps: why production is unreadable without them
  13. Debugging the network: the Network panel
  14. Debugging storage: the Application panel
  15. Debugging on a real phone
  16. Case 1: the task that will not change status
  17. Case 2: the filter that loses tasks on reload
  18. Case 3: the hours counter that does not add up
  19. The bug that will not reproduce
  20. Common Mistakes and Tips
  21. Exercises
  22. Conclusion

  1. Why debugging by intuition does not scale

Almost everybody starts debugging the same way: read the code where you think the bug is, spot something odd, change it, reload, see whether it still fails. Repeat. This is called debugging by random change, and it has three serious problems:

  • It does not converge. Every change alters the system, so the bug may disappear for a reason different from the one you believed, and come back a week later.
  • It introduces new bugs. An if added "just in case" that covers up the symptom leaves the system with two problems instead of one.
  • It teaches you nothing. When the bug goes away you do not know why it went away, and therefore you cannot avoid the next one in the same family.

The alternative is to treat the bug as what it is: a difference between what the program does and what you think it does. Debugging means pinpointing the exact spot where your mental model and reality part company. And there is a procedure for that.

An idea worth internalizing right now: 90% of debugging time is spent locating the bug, not fixing it. The fix is usually one line. That is why the whole method is aimed at locating fast, not at typing fast.

  1. The method: six steps

flowchart TD
    A["1 · Reproduce<br/>exact, reliable steps"] --> B["2 · Isolate<br/>reduce to the minimal case"]
    B --> C["3 · Hypothesis<br/>a falsifiable statement"]
    C --> D["4 · Verify<br/>breakpoint, log or test"]
    D -->|hypothesis false| C
    D -->|hypothesis true| E["5 · Fix<br/>the cause, not the symptom"]
    E --> F["6 · Prevent<br/>a test that fails without the fix"]

The six steps, and what each one means in practice:

Step What you do How you know you are done
1 · Reproduce You write down the exact sequence of actions that triggers the bug You can trigger it at will, three times in a row
2 · Isolate You strip out everything that is not essential: data, modules, steps A minimal case that still fails
3 · Hypothesis You state a concrete, checkable sentence: "id arrives as a string" The sentence can be proved false with a piece of data
4 · Verify You set a breakpoint or a log that confirms or denies it You have an observed value, not an impression
5 · Fix You fix the cause, in the right layer The minimal case passes and you understand why
6 · Prevent You write an automated test that fails without the fix The test turns green when the fix is applied

Step 6 is the one almost everybody skips, and it is the one that turns a wasted afternoon into a permanent asset. You do not know how to write those tests yet —that comes in Unit Testing with Jest— but in this lesson you are already going to write down the check that ought to exist for each bug. By the time you reach 08-03 you will have three tests waiting for you.

A golden rule for step 3: a hypothesis that cannot be proved false is not a hypothesis. "I think there is something odd in the filter" is useless. "I think state.filters.assignee is 'Iván' when it should be null" is useful, because a single glance settles it.

  1. Bisection: splitting the problem in half

When the bug is somewhere along a long route —the click comes in through controller.js, goes through board.js, comes back via board-view.js and ends up in local-repository.js— searching by reading is slow. The right technique is bisection: pick a point halfway along the route, check whether the data is already wrong there, and discard the corresponding half.

flowchart LR
    A["Click<br/>controller"] --> B["Board<br/>changeStatus"]
    B --> C["Event<br/>task:changed"]
    C --> D["View<br/>reconcile"]
    D --> E["Repository<br/>save"]
    style C fill:#fde68a,stroke:#b45309

If the data is already wrong at the midpoint (C), the bug is between A and C. If it is fine, it is between C and E. Every check halves the ground. With four checks you cover a sixteen-step route.

Bisection applies to three different dimensions, and it is worth knowing all three:

  • In the data flow, as you have just seen: checking at intermediate points along the path.
  • In time, with git bisect: if the application worked thirty commits ago and does not now, Git can binary-search for the guilty commit in five steps.
git bisect start
git bisect bad                 # the current HEAD fails
git bisect good a455c72        # this commit worked
# Git drops you on a commit in the middle: you test and answer
git bisect good                # or: git bisect bad
# ... five iterations ...
git bisect reset               # back to where you were
  • In the code, by commenting out half of it: if you disable connectForm and the bug disappears, you already know which side to look at. It is the crudest version, but in an interface with many listeners it is surprisingly effective.

  1. The console beyond console.log

console.log is a legitimate tool —anyone who says a professional never uses it is lying— but the console object has a dozen methods that solve specific problems better. These are the ones that actually get used:

Method What it is for Example in Nómada Tasks
console.table(data) Shows an array of objects as a sortable table console.table(board.tasks.map((t) => t.toJSON()))
console.dir(obj) Shows the object as a structure, not as text console.dir(document.querySelector('#task-list'))
console.group() / groupEnd() Groups and collapses related log lines One group per render
console.count(label) Counts how many times a point is reached Spotting duplicate renders
console.time() / timeEnd() Measures the time between two points console.time('render') … console.timeEnd('render')
console.assert(cond, msg) Logs only if the condition is false console.assert(r.openHours === 45, r)
console.trace() Prints the call stack without stopping anything Who called save()?
console.warn / error Severity level: they can be filtered by level Warnings from the repository

Four of them deserve a full example, because they genuinely change the way you work.

console.table is the difference between understanding an array of six objects at a glance and not understanding it at all. It takes a second argument with the columns you want:

// In the console, with the application open
console.table(
  board.tasks.map((t) => t.toJSON()),
  ['id', 'title', 'assignee', 'status', 'estimatedHours']
);
┌─────────┬────┬──────────────────────────────┬──────────┬───────────────┬────────────────┐
│ (index) │ id │ title                        │ assignee │ status        │ estimatedHours │
├─────────┼────┼──────────────────────────────┼──────────┼───────────────┼────────────────┤
│ 0       │ 1  │ 'Redesign the multipurpose…' │ 'Iván'   │ 'in-progress' │ 12             │
│ 1       │ 2  │ 'Signage for the screen-…'   │ 'Marta'  │ 'pending'     │ 6              │
│ 2       │ 3  │ 'Update the bookings web…'   │ 'Lucía'  │ 'pending'     │ 14             │
│ 3       │ 4  │ 'Screen-printing ink inv…'   │ 'Marta'  │ 'done'        │ 3              │
│ 4       │ 5  │ 'Bookbinding guide for r…'   │ 'Iván'   │ 'in-progress' │ 8              │
│ 5       │ 6  │ 'Carpentry workshop quot…'   │ 'Iván'   │ 'pending'     │ 5              │
└─────────┴────┴──────────────────────────────┴──────────┴───────────────┴────────────────┘

The DevTools table columns sort when you click the header. Sorting by status and counting is faster than any hand-written filter.

console.count answers the most frequent question in any interface: why does this run three times?

// js/view/board-view.js, inside update()
console.count('board render');
board render: 1
board render: 2
board render: 3     ← a single click caused three renders

That result is already a diagnosis: either three different listeners are reacting to the same event, or the event bubbles and is handled twice. Without count, that bug only shows up as "it feels a bit slow".

console.assert is a statement that only speaks when it is broken. It is perfect for watching known invariants, such as the canonical numbers of the backlog:

const r = board.summary(TODAY);
console.assert(r.openHours === 45, 'Open hours do not add up', r);
console.assert(r.effort === 124, 'Weighted effort does not add up', r);
// If all is well it prints nothing. If it fails:
// Assertion failed: Open hours do not add up {total: 6, open: 5, openHours: 48, …}

console.group turns a dumping ground of lines into a collapsible tree:

export function saveBoard(board) {
  console.group(`[repository] save · ${board.total} tasks`);
  console.log('key:', 'nomada:board:v1');
  console.table(board.summary(TODAY));
  const ok = repository.save(board);
  console.log('result:', ok ? 'saved' : 'no space');
  console.groupEnd();
}

With console.groupCollapsed() the group starts out collapsed, which is what you want when you log something that happens many times.

  1. The curly-brace trick: console.log({ variable })

This small trick saves more time than it looks. Compare:

console.log(assignee, status, visible.length);
// Iván pending 3            ← which one was which?

console.log({ assignee, status, visible: visible.length });
// {assignee: 'Iván', status: 'pending', visible: 3}    ← with names

By wrapping the variables in braces you are using the ES6 property shorthand (04-01): { assignee } is { assignee: assignee }. The result is an object where every value is labeled with the name of its variable, and on top of that it is displayed collapsible and inspectable in the console. Adopt it as a rule: it costs nothing and it wipes out the entire class of "I am reading the log of a different variable" mistakes.

Two companions in the same style:

// Marking the exact spot, useful when there are several identical logs
console.log('[controller:advance]', { id, currentStatus: task.status });

// Copying a large value to the clipboard to paste into a file
copy(board.tasks.map((t) => t.toJSON()));   // copy() only exists in the console

copy() is not standard JavaScript: it is one of the DevTools console utility functions, along with $0 (the last element selected in the inspector), $_ (the last result) and $$('selector') (a querySelectorAll that returns a real array). They only work when typed by hand in the console, never in your code.

  1. The debugger statement and your first breakpoint

console.log tells you the value of whatever you thought of printing. The debugger lets you look at everything, at the exact moment, and carry on from there. The difference in power is enormous.

The fastest way into the debugger is the debugger keyword:

changeStatus(id, next) {
  const task = this.findById(id);
  debugger;                                    // ← the browser stops HERE
  if (task === null) throw new ValidationError(`Task ${id} does not exist.`, 'id', id);
  task.changeStatus(next);
  return this;
}

With DevTools open, execution freezes on that line and you can inspect id, next, this and everything else. With DevTools closed, the statement does nothing.

debugger has one advantage (it is one line, it always works, it survives a file change) and one serious danger: if it slips into production, it freezes the application for anyone with the tools open. In the next lesson you will configure an ESLint rule (no-debugger) that prevents exactly that.

The usual approach, however, is not to touch the code: in the Sources panel (Chrome/Edge) or Debugger (Firefox), you open the file and click on the line number. A blue marker appears: that is an ordinary breakpoint.

  1. The six types of breakpoint

This is where the debugger stops being "a fancier console.log" and becomes something else entirely. These are the six types and when to use each one:

Type How you set it When it is the right tool
Normal Click on the line number You want to stop at that point every time
Conditional Right-click → Add conditional breakpoint The line runs 200 times and you only care about one case
Logpoint Right-click → Add logpoint You want the value without stopping and without touching the code
Event listener Event Listener Breakpoints → Mouse → click You do not know which code handles that click
Request XHR/fetch Breakpoints → add /tasks You want to stop just before a particular request
DOM change Right-click on the node → Break on… An element changes and you do not know who changes it

The conditional one saves the most time in this project. The paintCard function runs six times per render; stopping at all six is pointless. With the condition task.id === 3 you stop only at the one you care about:

// Condition typed in the breakpoint dialog (not in the code):
task.id === 3 && task.status === 'pending'

The condition is an ordinary JavaScript expression evaluated in the scope of that line. A little-known trick: if you type console.count('step') as the condition, it never stops (because it returns undefined, which is falsy) but it does run the counter. That is exactly what a logpoint does, and a logpoint is the official version of the idea.

The logpoint deserves a paragraph of its own because it replaces 80% of the console.log calls people write:

// In the logpoint dialog, on the changeStatus line:
'changing', {id, type: typeof id, next, currentStatus: task?.status}

Advantages over writing the console.log into the file: you do not modify the code, there is nothing to reload, there is no risk of forgetting it in a commit, and it works just the same over third-party or minified code. When you are done you delete the point and no trace is left.

Event listener breakpoints answer the question "what code runs when I press this button?" in an application you do not know. You enable Mouse → click, press the button, and the debugger drops you inside the handler, with the complete call stack. In Nómada Tasks that would take you straight to the delegated listener in js/view/controller.js, with event.target already available.

Request breakpoints (XHR/fetch Breakpoints) stop just before a request goes out whose URL contains the text you specify —for example tasks. They are useful for inspecting the body you are about to send before the server rejects it, and for discovering who fires an unexpected request: the call stack tells you.

DOM change breakpoints are the answer to the classic "this class removes itself". In the inspector, right-click on the node → Break on → attribute modifications, and the debugger stops on the line of JavaScript that modifies that attribute. There are three variants:

Variant Fires when
subtree modifications A descendant is added or removed (useful for reconcile)
attribute modifications An attribute changes: class, data-status, disabled, hidden
node removal The node itself is removed from the tree

  1. Stepping through code: step over, into and out

Once you are paused, you control execution with four buttons. Understanding them properly is what separates using the debugger from suffering it:

Action Usual shortcut What it does
Resume F8 Carries on until the next breakpoint
Step over F10 Runs the whole line without stepping into the functions it calls
Step into F11 Steps inside the function called on this line
Step out Shift + F11 Finishes the current function and returns to its caller

The practical rule is simple:

  • Use step over by default. You read the flow of the current function without getting lost in the calls.
  • Use step into only when you suspect the function you are calling.
  • Use step out as soon as you realize you have stepped somewhere you did not want to be (typically, inside a library function).

With reconcile, paintCard and map in the way, it is easy to end up twenty frames deep inside somebody else's code. To avoid it there is Ignore List (formerly blackboxing): you mark a file or a pattern as ignored and the debugger never steps into it, nor shows it in the stack. Mark your dependencies there and the stack becomes readable instantly.

  1. The Scope, Watch and Call Stack panels

When execution is paused, the right-hand half of the panel is where the real debugging happens.

Scope shows variables grouped by scope, and it is the empirical proof of everything you studied in 03-04 and 03-05:

Scope
├─ Local          ← the variables of the current function
│    task: Task {id: 3, title: 'Update the bookings web…'}
│    next: "in-progress"
├─ Closure (connectBoard)      ← the closure, visible!
│    board: Board {name: 'Taller Nómada'}
│    today: "2026-09-20"
├─ Module         ← what was imported and declared in the module
└─ Global         ← window

That Closure block is literally the captured environment 03-04 talked about, listed with names and contents. If you ever doubted that closures exist physically, here they are.

Watch is a list of expressions that are re-evaluated at every step. You are not limited to variables: you can watch complete calculations.

// Useful Watch expressions while debugging Nómada Tasks
board.summary('2026-09-20').openHours
board.tasks.filter((t) => t.isOpen).length
state.filters
document.querySelectorAll('#task-list > li').length

Watching openHours change from 45 to 39 at the exact step where it happens is information you simply cannot get from scattered logs.

Call Stack is the call stack: who called whom to get here. It reads top to bottom, most recent to oldest:

paintCard              card.js:24           ← you are here
reconcile              dom.js:38
update                 board-view.js:96
(anonymous)            app.js:41            ← the event listener

Clicking any frame takes you to it with its corresponding Scope, without losing the pause. That is how you answer "what arguments was I called with?" when the problem is not here but in the caller.

And one little-known, very useful feature: Restart frame. Right-click on a frame → Restart frame runs that function again from the top, with the same arguments. If you overshot the interesting line with one F10 too many, there is no need to reload the page and repeat the ten clicks that got you here: you restart the frame and go through again. (With one caveat: side effects that already happened —a request sent, a setItem done— are not undone.)

  1. Reading a stack trace and "Pause on exceptions"

An uncaught error prints something like this:

ValidationError: Transition not allowed: "done" → "in-progress".
    at Task.changeStatus (task.js:71:13)
    at Board.changeStatus (board.js:52:11)
    at HTMLUListElement.<anonymous> (controller.js:38:15)

You read it like this:

  • First line: error type and message. You already know this is rule R6 being broken.
  • Stack line 1: where it was thrown. task.js:71:13 = file, line 71, column 13.
  • Following lines: the chain of calls backwards. The last one is usually the real entry point.
  • HTMLUListElement.<anonymous>: an anonymous function attached as a listener on a <ul>. It is the delegated listener from 06-04.

The most common mistake when reading a stack is looking only at the first line. The place where it is thrown is almost never the place where the bug is. Here the error is thrown in Task, but the cause is in controller.js:38: somebody tried to reopen an already-finished task because the button was not disabled. The first line says what happened; the ones below say why.

"Pause on exceptions" (the ⏸ icon with the diamond, in the Sources panel) makes the debugger stop at the instant the error is thrown, with all the context alive. It has two levels:

Option Stops on When to switch it on
Pause on uncaught exceptions Only errors nobody catches Always; almost no noise
Pause on caught exceptions Also on the ones a catch catches When something fails silently

The second one is the hidden gem. Nómada Tasks catches errors in several places (the form's try/catch, the repository's catch that discards corrupt data, withRetries). If a bug is being swallowed in one of those catch blocks, switching on "pause on caught exceptions" takes you straight to the original throw. That said: only switch it on while you are investigating, because it will also stop on caught errors that are perfectly normal.

  1. Debugging asynchronous code

This is where the classic debugger used to give up. When a setTimeout, a promise or an await resumes execution, the original call stack has already been emptied —that is exactly the event loop mechanism you studied in 05-07. Without help, the stack would show only this:

(anonymous)            http.js:112

Useless: it does not say who asked for that request. That is why modern browsers keep async stacks, which stitch the current frame to the one that scheduled the task:

fetchJson                    http.js:112
  ── Async: await ──                          ← the stitch
listTasks                    tasks-api.js:47
  ── Async: await ──
loadBoard                    app.js:63
  ── Async: promise callback ──
(anonymous)                  controller.js:52

Now we are talking: the request was born from a click in the controller. Four concrete tips for debugging asynchrony in this project:

  • Put the breakpoint after the await, not before. Before it you only see a pending promise; after it you see the resolved value.
  • Use a conditional breakpoint in withRetries with the condition attempt > 1: you stop only once something has already failed.
  • In the Network panel switch on the Initiator column. Like the async stack, it tells you which line fired each request.
  • Beware of the races the debugger itself causes. By pausing execution for five seconds, an 8 s timeout can expire. If you suspect a race condition, prefer logpoints over pauses.

A very useful pattern for asynchrony is measuring where the time goes without slowing anything down:

export async function listTasksTimed(filters) {
  console.time('listTasks');
  try {
    return await listTasks(filters);
  } finally {
    console.timeEnd('listTasks');    // runs even if it throws
  }
}
// listTasks: 843.21 ms

The finally guarantees that timeEnd runs even if the request fails; otherwise an error would leave the timer open and the next console.time would warn about a duplicate label.

  1. Source maps: why production is unreadable without them

The code you deploy is not the code you write. A bundler joins it, minifies it and renames the variables, so an error in production looks like this:

TypeError: Cannot read properties of null (reading 'dataset')
    at t (app.4f3a1b.js:1:24817)

t, line 1, column 24817. Without extra information, that clue is worth nothing.

A source map is a file (app.4f3a1b.js.map) containing the translation dictionary between the generated code and the original: which position in the minified file corresponds to which file, line, column and variable name in the source. The browser loads it if it finds the trailing comment:

//# sourceMappingURL=app.4f3a1b.js.map

And then the very same error is shown like this:

TypeError: Cannot read properties of null (reading 'dataset')
    at handleClick (js/view/controller.js:34:22)

Three practical decisions about source maps:

Scenario Recommendation Reason
Development Always on The size cost is irrelevant locally
Production, public application Generate them, but do not publish them for anyone to grab They are uploaded to the monitoring service; an anonymous browser does not download them
Production, internal tool Publish them without worry The code is not secret and debugging incidents is easier

Bear in mind that a source map reconstructs your source code. Publishing it is equivalent to publishing the unminified code. That is not a security flaw in itself —security must never depend on obfuscation— but it is a conscious decision you have to make.

In DevTools, if a source map fails to load, the Sources tab shows a warning in the console (DevTools failed to load source map). The three usual causes: the .map was not deployed, the path in the trailing comment is wrong, or the server returns a 404 for it after an aggressive caching configuration.

  1. Debugging the network: the Network panel

Module 7 filled Nómada Tasks with requests. When one fails, the Network panel answers in seconds what the code cannot tell you:

  • Filter by Fetch/XHR to see only your requests, without images or CSS.
  • The Status column distinguishes what fetch does not: a 200 with an empty body, a 404, a 500, or (failed) for a transport failure.
  • The Headers tab shows what you sent and what came back, including the Content-Type header that makes fetchJson fail when HTML arrives.
  • The Payload tab shows the body of the POST exactly as it went out: this is where you discover the undefined values that JSON.stringify silently removes.
  • The Timing tab breaks the time down: Stalled, Waiting (TTFB), Content Download. If TTFB is 4 s, it is not your JavaScript's fault.

Three features of the panel worth knowing:

Copy as fetch. Right-click on a request → Copy → Copy as fetch. Paste the result into the console and you reproduce the exact request, with its headers, as many times as you like. It is the best way to isolate whether the problem is on the server or in your code: if the copied request works in the console and not in the application, the bug is yours.

// Pasted from "Copy as fetch" and modified to test a hypothesis
await fetch('https://api.tallernomada.example/v1/tasks?assignee=Iv%C3%A1n', {
  headers: { accept: 'application/json' }
}).then((r) => ({ ok: r.ok, status: r.status, type: r.headers.get('content-type') }));

Throttling. The network selector (No throttling / Slow 4G / Offline) simulates slow connections. It is essential for testing two things you wrote in Module 7 that you never see locally: the loading states from 07-03 and the offline behavior of the service worker from 07-05. On a 1 ms local network, the loading state appears and disappears before it can even render.

Preserve log. Tick this box if the request you are investigating causes a navigation or a reload; without it, the log is cleared and the guilty request vanishes.

  1. Debugging storage: the Application panel

For everything from 07-01 and 07-05, the Application panel is your direct window onto the persisted state:

Section What you inspect in Nómada Tasks
Local Storage The nomada:board:v1 key, its JSON and its size
Session Storage Ephemeral state for the tab
IndexedDB (Not used here, but where a larger application would look)
Cache Storage The resources precached by the service worker
Service Workers The worker's state: installed, active, waiting; Update on reload, Unregister
Manifest How the browser interprets your manifest.json

The localStorage value can be edited in place: you double-click the value, change the JSON and reload. It is the fastest way to check how your code reacts to corrupt data, to an old version format, or to an empty board… without writing a single line. And Clear site data returns you to a clean start, which is the state in which you should reproduce any bug before taking it at face value.

  1. Debugging on a real phone

The DevTools device emulator changes the size and simulates touch, but it is not Safari on an iPhone nor Chrome on a low-end Android. The real bugs —a 100vh that gets eaten by the address bar, a touch event that does not bubble, an API that does not exist on that version— only show up on the device.

Remote inspection connects your computer's DevTools to the page running on the phone:

  • Android + Chrome: switch on Developer options and USB debugging on the phone, connect it with a cable and open chrome://inspect#devices on the computer. The phone's tab appears in the list; you press inspect and you have the full DevTools, debugger and Network panel included.
  • iOS + Safari: switch on Settings → Safari → Advanced → Web Inspector on the iPhone and Safari → Settings → Advanced → Show Develop menu on the Mac; the device appears under the Develop menu.

And to test Nómada Tasks on the phone, the phone needs to reach your development server. Two routes:

# Option A · same wifi network: serve on all interfaces and use the local IP
npx serve -l tcp://0.0.0.0:5000
# The phone opens http://192.168.1.42:5000

# Option B · public tunnel with HTTPS (needed for real service workers)
npx localtunnel --port 5000

Option B matters because of a detail from 07-05: service workers require HTTPS except on localhost. And localhost on the phone is the phone itself, not your laptop. Without an HTTPS tunnel you will not be able to debug the PWA on a real device.

  1. Case 1: the task that will not change status

Let us apply the full method to a real bug.

The bug report. Marta writes: "I created the task Check the fire extinguishers with the form and then I pressed Start and nothing happens. The others do work."

Step 1 · Reproduce. First, a clean start (Clear site data). Then, the exact sequence:

  1. Open the application with the canonical backlog (6 tasks).
  2. Press Start on task 2 → it works.
  3. Create a new task with the form.
  4. Press Start on the new task → nothing happens.

Reproduced, and with a golden clue: it fails only on tasks created during the session. The backlog ones are fine.

Step 2 · Isolate. Is it the form or the creation? Let us try creating a task from the console, without touching the form:

const t = await createTask({ title: 'Test', assignee: 'Iván', priority: 'low',
                             estimatedHours: 2, dueDate: '2026-10-30', tags: [] });
board.add(t);
view.update();
// Press "Start" on that card → it does not work either

The form is ruled out: the problem is in tasks that come from createTask, that is, from the API.

Step 3 · Hypothesis. The click flow is: controller reads li.dataset.id → calls board.changeStatus(id, ...) → findById(id) compares with t.id === id. And dataset always returns strings. For backlog tasks, id is a number in the model; for API ones… the concrete hypothesis is:

Task.fromJSON is storing id as a string for tasks that arrive from the server, and findById uses ===, which does not convert types (01-07). That is why findById('7') returns null and does not find the task.

Hold on: if it returned null, Board.changeStatus would throw a ValidationError. Why do we see nothing? Because the controller catches that error to display it, and the catch writes it into a container that is hidden. Second part of the hypothesis: the error is thrown and swallowed.

Step 4 · Verify. Two checks, neither of which modifies the code:

  • Switch on Pause on caught exceptions, press the button. The debugger stops… at the throw in Board.changeStatus. Hypothesis B confirmed.
  • Set a logpoint on the findById line with {id, idType: typeof id, ids: this.tasks.map((t) => [t.id, typeof t.id])}:
{id: '7', idType: 'string', ids: [[1,'number'],[2,'number'],…,[7,'string']]}

Confirmed beyond doubt. The model's id is the string '7', dataset also returns a string, but the controller was doing Number(li.dataset.id) before calling, so it compares 7 === '7' → false.

Step 5 · Fix the cause. Three possible fixes, and only one is right:

Fix Where Verdict
Change === to == in findById Model ❌ Covers up the symptom and reintroduces the coercion 01-07 advises against
Convert to a number in the controller View ❌ The model would still hold mixed types; the bug would resurface elsewhere
Normalize the type at the data boundary Task.fromJSON ✅ A single point, and the invariant "id is a number" always holds
// js/model/task.js — the boundary normalizes the types
static fromJSON(data) {
  const plain = typeof data === 'string' ? JSON.parse(data) : data;
  return new Task({ ...plain, id: Number(plain.id) });   // ← the id is ALWAYS a number
}

And while we are at it, the catch that was swallowing the error stops being silent: if the error container is hidden, it is shown. An error nobody sees is an error that exists twice.

Step 6 · Prevent. The test that ought to exist, and that you will write in 08-03:

// Pending for 08-03:
// "Task.fromJSON converts a string id into a number"
//   → Task.fromJSON({ ...data, id: '7' }).id === 7  (and typeof === 'number')
// "Board.findById finds a task imported from the server"

  1. Case 2: the filter that loses tasks on reload

The report. Iván: "I filtered by my name to see my tasks, I closed the laptop, and when I came back there were only three tasks left on the whole board. Marta's and Lucía's had vanished."

Step 1 · Reproduce. With the canonical backlog: filter by Iván (3 remain visible), reload (F5) → the board has 3 tasks and the summary says 25 h. Reproducible 100% of the time. And it is a data loss bug, the worst category: top priority.

Step 2 · Isolate. The key question: was it saved wrong, or is it read wrong? The Application panel answers it without touching the code. We filter by Iván (without reloading) and look at Local Storage → nomada:board:v1:

{ "name": "Taller Nómada", "version": 1, "tasks": [
  { "id": 1, "title": "Redesign the multipurpose room", "assignee": "Iván", … },
  { "id": 5, "title": "Bookbinding guide for residents", … },
  { "id": 6, "title": "Carpentry workshop quote", … } ] }

Only three tasks already written. The bug is in the write, not the read. We have just discarded half the route with a single glance.

Step 3 · Hypothesis. Who calls save and with what? Here the tool is console.trace() placed as a logpoint in LocalRepository.save, with the expression:

'save', {total: board.total}, console.trace()
save {total: 3}
console.trace
    at HTMLDocument.<anonymous>       (app.js:78)
    at BoardView.update               (board-view.js:104)

The hypothesis writes itself:

app.js is saving what the view is showing instead of the whole board. When you filter, the view has 3 tasks and that is what gets persisted, wiping out the other 3.

Step 4 · Verify. We open app.js:78:

document.addEventListener(EVENTS.FILTER_APPLIED, () => {
  view.update({ filters: readStateFromUrl() });
  repository.save(new Board(board.name, view.visibleTasks));   // ← here
});

Somebody, while adding the router from 07-06, wanted to "save the state when filtering" and built a new board out of the visible tasks. Hypothesis confirmed with the line in front of us.

Step 5 · Fix. The real cause is conceptual, and it deserves stating: a value derived from the view has been persisted as if it were model state. The filter is presentation (06-06) and its home is the URL (07-06); the board is the model and its home is the store.

// js/app.js — fixed
document.addEventListener(EVENTS.FILTER_APPLIED, (event) => {
  view.update({ filters: event.detail });
  writeStateToUrl(event.detail);             // the filter lives in the URL…
});

// …and the model is saved only when the MODEL changes
document.addEventListener(EVENTS.TASK_CHANGED, () => repository.save(board));
document.addEventListener(EVENTS.TASK_CREATED, () => repository.save(board));

Step 6 · Prevent. Two checks for 08-05, where model and data are tested together:

// Pending for 08-05:
// "filtering the view does not alter what is persisted"
//   → filter by 'Iván', and repository.load().total is still 6
// "saving after a status change keeps all 6 tasks"

  1. Case 3: the hours counter that does not add up

The report. Lucía: "The summary says 48 h open as soon as I open it. It should be 45; the inventory task has been done for a week."

This case is different from the previous ones: there is nothing to reproduce, it always fails. And there is something much better than a bug report: there is an oracle. The canonical backlog has known numbers —48 h total, 45 h open, effort 124— and the program contradicts one of them.

Step 2 · Isolate. A single command in the console separates the model from the view:

board.summary('2026-09-20');
// { total: 6, open: 5, totalHours: 48, openHours: 48, overdue: 1, effort: 124 }

The model already returns 48. The view is innocent: it only paints what it is given. And there is a revealing detail: open: 5 is correct (5 open tasks out of 6), but openHours matches totalHours exactly. That is not a calculation error, it is a selection error: all six are being added up.

Step 3 · Hypothesis.

openHours is reducing over all the tasks instead of over the open ones.

Step 4 · Verify. We set the breakpoint in the getter and use Watch with two expressions at once:

// In Watch:
this.openTasks.length                                       // → 5
this.openTasks.reduce((s, t) => s + t.estimatedHours, 0)    // → 45   ← the correct value

The correct expression gives 45. We look at the code:

get totalHours() { return this.#tasks.reduce((s, t) => s + t.estimatedHours, 0); }
get openHours()  { return this.#tasks.reduce((s, t) => s + t.estimatedHours, 0); }
//                        ^^^^^^^^^^^  should be this.openTasks

A copy and paste. The classic bug: two nearly identical lines, one of them never adapted. Always be suspicious of twin lines.

An extra check with git, to find out when it got in and rule out further damage in the same change:

git log -L :openHours:js/model/board.js
# shows the complete history of THAT function, with the commit that broke it

Step 5 · Fix.

get openHours() { return this.openTasks.reduce((s, t) => s + t.estimatedHours, 0); }

Step 6 · Prevent. This case is the perfect argument for the whole module. A bug like this:

  • Throws no error.
  • Breaks no screen.
  • Is invisible unless somebody knows the right number.
  • And it comes out of a layer —the model— that can be checked without a browser, without a DOM and without a network, with a function that takes data and returns data.
// Pending for 08-03 (it will literally be the first test you write):
// "the canonical backlog summary gives 45 h open out of 48 and effort 124"

In the meantime, a thirty-second safety net you can leave in place right now, switched on by a flag:

// js/app.js — invariant check, development only
if (localStorage.getItem('nomada:diag') === '1') {
  const r = board.summary(TODAY);
  console.assert(r.totalHours === r.openHours + 3, 'openHours does not add up', r);
  console.assert(r.total === r.open + 1, 'open count does not add up', r);
}

  1. The bug that will not reproduce

That leaves the hardest category: "sometimes, when I come out of the lift, my tasks get duplicated". There are no steps, no screen, no stack. Four tools, in order of increasing effort:

1 · Structured logging. Replace scattered console.log calls with a single logger that emits objects with context. Objects can be filtered, counted and sent; free-form text cannot.

// js/util/logger.js
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
let threshold = LEVELS.warn;                                 // in production, only warn and error

export function setLevel(name) { threshold = LEVELS[name] ?? LEVELS.warn; }

export function log(level, event, data = {}) {
  if (LEVELS[level] < threshold) return;
  const line = {
    ts: new Date().toISOString(),
    level,
    event,                                    // 'task:changed', 'api:error', 'sw:activated'
    session: sessionId(),                     // the same id throughout the visit
    ...data
  };
  console[level === 'debug' ? 'log' : level](line);
  history.push(line);                         // circular buffer in memory
  if (history.length > 200) history.shift();
}

const history = [];
export const dumpHistory = () => [...history];               // to attach to a report

The circular buffer is the key: when the rare bug finally happens, you have the 200 previous lines, not just the moment of the disaster. That is exactly the context missing from incidents that will not reproduce.

2 · Diagnostic flags. You cannot ask Marta to open DevTools. But you can give her a switch:

// Turned on with ?diag=1 in the URL, and persistent until it is turned off
const params = new URLSearchParams(location.search);
if (params.has('diag')) localStorage.setItem('nomada:diag', params.get('diag'));
if (localStorage.getItem('nomada:diag') === '1') setLevel('debug');

And a "Copy diagnostic report" button that puts on the clipboard the history, the board summary, the application version and navigator.userAgent. A report like that turns "it fails sometimes" into a reproducible case.

3 · Reproduce the conditions, not the steps. Intermittent bugs almost always come from four places: the network (use Slow 4G and Offline), time (a task that is due today and was not yesterday), concurrency (two open tabs with the storage event from 07-01) and prior state (a localStorage from an old version). Trigger those conditions deliberately and many "unreproducibles" become reliable.

4 · Error monitoring. For what happens on somebody else's computer, the industry solution is a monitoring service (Sentry, Rollbar, Bugsnag and the like). The essential mechanism fits in ten lines, and understanding it matters more than the particular vendor:

// Global capture: synchronous errors and unhandled rejected promises
window.addEventListener('error', (e) => {
  reportIncident({ type: 'error', message: e.message, file: e.filename,
                   line: e.lineno, stack: e.error?.stack });
});

window.addEventListener('unhandledrejection', (e) => {          // ← the one from 05-06
  reportIncident({ type: 'promise', message: String(e.reason), stack: e.reason?.stack });
});

What a service adds on top of this: it groups identical incidents, applies the source maps from section 12 to show your original code, records the trail of the user's previous actions, and alerts you when a new bug appears after a deployment. Two essential warnings: do not send personal data in the context (nor task titles, if they may contain sensitive information), and respect whatever data protection regulations apply.

Common Mistakes and Tips

  • Changing the code before understanding the bug. If you cannot explain why it fails, your fix is a bet. First the verified hypothesis, then the edit.
  • Debugging in a dirty state. A localStorage holding data from three previous experiments produces phantom bugs. Always reproduce from Clear site data, and in a private window if you suspect an extension.
  • Confusing where the error is thrown with where the bug is. Read the whole stack, bottom to top. The culprit is usually two frames below the throw.
  • Forgetting that dataset returns strings. It is the source of case 1 and of half the identity bugs in DOM applications. Normalize types at the boundary, always.
  • console.log of an object and believing you are seeing its value at that moment. The console shows objects live: when you expand it you may be seeing its current state, not the state at the time of the log. If you need the snapshot, log structuredClone(obj) (04-08) or JSON.stringify(obj).
  • Putting the breakpoint before the await. Put it after: before it you will only see a pending promise.
  • Leaving debugger or debug logs in a commit. This is solved in the very next lesson, with no-debugger and no-console in ESLint plus a Git hook.
  • Covering errors up with an empty try { … } catch {}. An empty catch turns a noisy failure into a silent one, which is infinitely worse. If you really do want to ignore something, write down why in a comment.
  • Tip: write the hypothesis down in a note before checking it. It forces you to be specific and prevents the drift of poking around at random. When you are done you will also have the material for the post mortem.
  • Tip: if you have been stuck for more than an hour, explain it to somebody. Rubber duck debugging works because putting things into words forces your assumptions out into the open. Half the time the solution turns up halfway through the explanation.
  • Tip: Ctrl/Cmd + P in the Sources panel opens any file by name, and Ctrl/Cmd + Shift + P is the DevTools command palette. With those two shortcuts you will stop hunting for files in the tree.

Exercises

Exercise 1 — Diagnosis with the method. A colleague reports: "If I click Start twice quickly on task 1, the card stays in in-progress but the summary says there are 4 open tasks instead of 5, and a ValidationError: Transition not allowed: "in-progress" → "in-progress" shows up in the console". Write the complete diagnosis following the six steps: reproduction steps, isolated minimal case, falsifiable hypothesis, which DevTools instrument you would use to check it (state the exact breakpoint type and its condition), which layer you would fix it in, and what test you would write to prevent it.

Exercise 2 — A logging module with levels and a buffer. Write the complete js/util/logger.js with: four levels (debug, info, warn, error), a threshold configurable by flag (?diag=1 in the URL, persisted in localStorage), a session identifier stable throughout the visit, a circular buffer of the last 200 lines, and dumpReport() returning an object with the history, the board summary, the application version and the userAgent, ready to copy to the clipboard. It must output through console.debug/info/warn/error according to the level so that the console filter works.

Exercise 3 — Instrumenting the flow of a click. Without modifying the application code, describe the DevTools setup that would let you follow the complete journey of a click on Start for task 3 —from the delegated listener to the write into localStorage— logging the relevant values at each point and never stopping execution. State the file, the approximate line and the exact expression of each logpoint, and what you would expect to see in the console if everything works properly.

Solutions

Solution 1

1 · REPRODUCE
   - Clear site data. Load with the canonical backlog.
   - Quick double click (< 100 ms) on "Start" for task 1.
   - It reproduces 3 times out of 3 if the second click arrives before the render.

2 · ISOLATE
   - Minimal case: two consecutive calls to controller.advance(1) with no render in between.
     board.changeStatus(1, 'in-progress'); board.changeStatus(1, 'in-progress');
   - Without a DOM: the second one throws the same ValidationError. The bug is NOT the browser's
     nor the double click: it is that the next status is computed from the DOM, not from the model.

3 · HYPOTHESIS (falsifiable)
   The controller computes the target status from `li.dataset.status`, which is only updated
   on render. Between the first click and its render, the dataset still says 'pending',
   so the second click asks for 'in-progress' again → invalid transition (R6).
   And the summary is recomputed in the catch with a hand-incremented counter that had
   already been added before the throw: hence the 4 instead of 5.

4 · VERIFY
   - CONDITIONAL breakpoint in controller.js, on the line that computes the target:
        condition:  id === 1
   - And a logpoint on the same line:
        'advance', {id, datasetStatus: li.dataset.status, modelStatus: board.findById(id).status}
     If the hypothesis is true, on the second click you will see:
        {id: 1, datasetStatus: 'pending', modelStatus: 'in-progress'}   ← they diverge
   - Switch on "Pause on caught exceptions" to confirm the error is swallowed in the catch.

5 · FIX (layer: view/controller)
   - The target status is ALWAYS computed from the model, never from the DOM:
        const task = board.findById(id);
        const target = NEXT[task.status];
        if (target === null) return;
   - The DOM is a projection of the state, not its source (the 06-06 cycle).
   - Also, the summary is recomputed with board.summary(TODAY), with no manual counters.

6 · PREVENT (for 08-03)
   - "two consecutive changeStatus calls to the same target throw ValidationError" (model).
   - "two clicks in a row on advance leave the task in 'in-progress' and the summary at 5 open"
     (integration with jsdom, 08-05).

Solution 2

// js/util/logger.js
const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });
const OUTPUT = Object.freeze({ debug: 'debug', info: 'info', warn: 'warn', error: 'error' });
const MAX_LINES = 200;

const history = [];
let threshold = LEVELS.warn;

/** Diagnostic flag: ?diag=1 turns it on and it stays on until ?diag=0. */
function readFlag() {
  const p = new URLSearchParams(location.search);
  if (p.has('diag')) localStorage.setItem('nomada:diag', p.get('diag'));
  return localStorage.getItem('nomada:diag') === '1';
}

/** One id per visit: lets you group all the lines from a single session. */
function sessionId() {
  let id = sessionStorage.getItem('nomada:session');
  if (id === null) {
    id = crypto.randomUUID();
    sessionStorage.setItem('nomada:session', id);
  }
  return id;
}

export function setLevel(name) {
  threshold = LEVELS[name] ?? LEVELS.warn;
}

export function log(level, event, data = {}) {
  const line = { ts: new Date().toISOString(), level, event, session: sessionId(), ...data };

  history.push(line);                          // the buffer keeps EVERYTHING, even what is not printed
  if (history.length > MAX_LINES) history.shift();

  if (LEVELS[level] >= threshold) console[OUTPUT[level]](`[nomada] ${event}`, line);
  return line;
}

export const debug = (event, data) => log('debug', event, data);
export const info  = (event, data) => log('info',  event, data);
export const warn  = (event, data) => log('warn',  event, data);
export const error = (event, data) => log('error', event, data);

/** Full report, ready to copy and attach to a bug report. */
export function dumpReport({ board, today, version = '1.0.0' } = {}) {
  return {
    version,
    generatedAt: new Date().toISOString(),
    session: sessionId(),
    userAgent: navigator.userAgent,
    diagnostics: readFlag(),
    summary: board ? board.summary(today) : null,
    history: structuredClone(history)          // deep copy: the snapshot, not the live object
  };
}

export async function copyReport(context) {
  await navigator.clipboard.writeText(JSON.stringify(dumpReport(context), null, 2));
}

// Startup
if (readFlag()) setLevel('debug');

Solution 3

DevTools setup (not a single line of code modified):

A · Ignore List
   Add any external dependency so that "step into" does not get lost.

B · Four logpoints (right-click on the line number → Add logpoint)

   1) js/view/controller.js — inside the delegated listener, after the closest():
      'A·click', {action: button?.dataset.action, id: button?.closest('[data-id]')?.dataset.id}
      → expected: {action: 'advance', id: '3'}          (a string! watch out for case 1)

   2) js/model/board.js — first line of changeStatus(id, next):
      'B·model', {id, type: typeof id, next, current: this.findById(id)?.status}
      → expected: {id: 3, type: 'number', next: 'in-progress', current: 'pending'}

   3) js/view/board-view.js — first line of update():
      'C·render', {visible: this.visibleTasks?.length, summary: this.state?.board.summary('2026-09-20')}
      → expected: {visible: 6, summary: {…, openHours: 45, …}}

   4) js/data/local-repository.js — first line of save(board):
      'D·persist', {total: board.total, bytes: JSON.stringify(board).length}
      → expected: {total: 6, bytes: ~1200}

C · Cross-checks
   - console.count switched on in logpoint C would detect duplicate renders.
   - If A and B show up but not C, the 'task:changed' event is not being emitted.
   - If A, B and C show up but not D, the persistence listener is missing (case 2).
   - If D says total: 3 while C says visible: 3, the view is being persisted (case 2).

D · Extra, without stopping anything
   A DOM change breakpoint (attribute modifications) on the <li data-id="3">
   would point at exactly which line writes data-status, useful if C runs but the
   card does not change.

Conclusion

You have changed the way you face a bug. It is no longer about staring at the code until something looks suspicious, but about walking through six steps: reproduce reliably, isolate down to the minimal case, state a falsifiable hypothesis, verify it with an instrument, fix the cause in the right layer and prevent it with a test. And you know that when the ground is large, bisection —in the data flow, in the history with git bisect, or in the code itself— turns a linear search into a logarithmic one.

You know the whole console, not just console.log: table to see six tasks at a glance, count to uncover duplicate renders, assert to watch invariants like the 45 open hours, group so you do not drown in lines, trace to find out who called, and the curly-brace trick console.log({ variable }) that labels every value with its own name. And you have real command of the debugger: the debugger statement and its risks, the six types of breakpoint —normal, conditional, logpoint, event listener, request and DOM change—, navigation with step over, into and out, the Scope panel (where the closures of 03-04 are finally visible), Watch with computed expressions and Call Stack with its Restart frame. You can read a stack trace bottom to top, switch on "Pause on caught exceptions" to hunt down the bugs that catch blocks swallow, follow an async stack stitched across the await calls of 05-07, and explain why without source maps a production error tells you nothing. And you have the Network panel with its Copy as fetch and its throttling, the Application panel for localStorage and the Module 7 service worker, and remote inspection for a real phone.

Above all, you have solved three real bugs with the method in front of you: an id that arrived as a string from the API and broke the === of 01-07 —fixed at the data boundary, not in the model nor in the view—; a filter that persisted the visible tasks instead of the whole board, confusing a value derived from the view with model state; and an openHours that added up all the tasks and returned 48 instead of 45, a silent bug that no screen gave away. And you have seen what to do when the bug will not reproduce: structured logging with a circular buffer, diagnostic flags you can switch on from the URL, reproducing conditions rather than steps, and a note on the monitoring services that apply your source maps to other people's errors.

The three cases share an uncomfortable ending: all three were closed with a test "still to be written". And all three, moreover, had an earlier warning that nobody saw. The id mixing types, the empty catch that swallowed the error, the two twin lines where one was never adapted: these are exactly the kind of problem a machine can flag before running anything, just by reading the code. Before automating the behavior checks it is worth automating the form checks, because they are cheaper and they catch a whole family of bugs on their own. That is what comes next: Code Quality: ESLint, Prettier and Conventions, where you will configure a static analyzer over Nómada Tasks, fix the warnings that turn up —including, without a shadow of a doubt, some forgotten debugger from this lesson— and put a guard in Git so that none of this ever reaches the repository again.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved