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
- Why debugging by intuition does not scale
- The method: six steps
- Bisection: splitting the problem in half
- The console beyond
console.log - The curly-brace trick:
console.log({ variable }) - The
debuggerstatement and your first breakpoint - The six types of breakpoint
- Stepping through code: step over, into and out
- The Scope, Watch and Call Stack panels
- Reading a stack trace and "Pause on exceptions"
- Debugging asynchronous code
- Source maps: why production is unreadable without them
- Debugging the network: the Network panel
- Debugging storage: the Application panel
- Debugging on a real phone
- Case 1: the task that will not change status
- Case 2: the filter that loses tasks on reload
- Case 3: the hours counter that does not add up
- The bug that will not reproduce
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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
ifadded "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.
- 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.
- 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
connectFormand 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.
- The console beyond
console.log
console.logconsole.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?
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.
- The curly-brace trick:
console.log({ variable })
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 namesBy 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 consolecopy() 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.
- The
debugger statement and your first breakpoint
debugger statement and your first breakpointconsole.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.
- 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 |
- 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.
- 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 ← windowThat 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').lengthWatching 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.)
- 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.
- 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:
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
withRetrieswith the conditionattempt > 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 msThe 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.
- 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:
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:
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.
- 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/XHRto see only your requests, without images or CSS. - The Status column distinguishes what
fetchdoes not: a200with an empty body, a404, a500, or(failed)for a transport failure. - The Headers tab shows what you sent and what came back, including the
Content-Typeheader that makesfetchJsonfail when HTML arrives. - The Payload tab shows the body of the
POSTexactly as it went out: this is where you discover theundefinedvalues thatJSON.stringifysilently 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.
- 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.
- 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#deviceson 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 5000Option 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.
- 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:
- Open the application with the canonical backlog (6 tasks).
- Press Start on task 2 → it works.
- Create a new task with the form.
- 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 eitherThe 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.fromJSONis storingidas a string for tasks that arrive from the server, andfindByIduses===, which does not convert types (01-07). That is whyfindById('7')returnsnulland 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
throwinBoard.changeStatus. Hypothesis B confirmed. - Set a logpoint on the
findByIdline with{id, idType: typeof id, ids: this.tasks.map((t) => [t.id, typeof t.id])}:
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"
- 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: 3}
console.trace
at HTMLDocument.<anonymous> (app.js:78)
at BoardView.update (board-view.js:104)The hypothesis writes itself:
app.jsis 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"
- 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.
openHoursis 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 valueThe 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.openTasksA 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 itStep 5 · Fix.
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);
}
- 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 reportThe 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
localStorageholding data from three previous experiments produces phantom bugs. Always reproduce fromClear 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
datasetreturns strings. It is the source of case 1 and of half the identity bugs in DOM applications. Normalize types at the boundary, always. console.logof 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, logstructuredClone(obj)(04-08) orJSON.stringify(obj).- Putting the breakpoint before the
await. Put it after: before it you will only see a pending promise. - Leaving
debuggeror debug logs in a commit. This is solved in the very next lesson, withno-debuggerandno-consolein ESLint plus a Git hook. - Covering errors up with an empty
try { … } catch {}. An emptycatchturns 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 + Pin the Sources panel opens any file by name, andCtrl/Cmd + Shift + Pis 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
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
