The baseline from 09-01 splits the blame across four fronts, and this lesson attacks the first: your own code taking too long to run. Those are rows 2, 4, 6 and 7 of the table —480 ms of INP when typing in the search box, a long task of 1,180 ms at startup, 96 ms thrown away recomputing the same thing and a 940 ms planning report that leaves the screen frozen. You are going to see how the engine works internally (just enough not to sabotage it, without falling into useless micro-optimizations), why algorithmic cost outranks every other consideration, how to compute once what gets used many times with a properly invalidated cache, when to use debounce and when throttle, how to chunk long work so the event loop can breathe, and how to finally get the genuinely heavy work off the main thread with a Web Worker. We will finish with a table of debunked myths, because almost everything people repeat about "fast JavaScript" stopped being true fifteen years ago.
Contents
- Where the time actually goes
- How the engine works: parsing, interpretation and JIT
- Hidden shapes, monomorphism and deoptimization
- Practical rules for not sabotaging the engine
- The cost that really rules: complexity
- From the quadratic loop to a
Mapindex - Computing only once: memoization
- A properly invalidated cache in
Board - Frequent handlers: debounce and throttle
- Chunking long work so the thread is not blocked
- Web Workers: a genuinely separate thread
- The cost of crossing the boundary: serialization and transferables
- The planning report, in a worker
- Debunked myths
- Common Mistakes and Tips
- Exercises
- Conclusion
- Where the time actually goes
Before touching anything, let us break down the 1,180 ms long task at startup. With the performance.mark/measure instrumentation from 09-01 and the Performance panel at CPU 4×, the split is this (median of 15 startups, 600 tasks, reference laptop):
| Startup phase | Duration | Is it your code? |
|---|---|---|
| Parsing and executing the 28 ES modules | 214 ms | Yes, but it is a loading problem (09-05) |
JSON.parse of the saved board (182 kB) |
41 ms | Yes, unavoidable |
600 × Task.fromJSON |
103 ms | Yes |
groupByTag() |
148 ms | Yes, and it is quadratic |
Initial render() |
310 ms | Yes, but it is DOM (09-04) |
summary() × 3 |
13 ms | Yes |
| Browser layout and paint | 244 ms | Not directly (09-04) |
| The rest (events, router, repository) | 107 ms | Yes |
| Total | 1,180 ms |
This breakdown already makes two decisions for us. First: groupByTag() burns 148 ms doing something conceptually trivial, and that smells of the wrong algorithm. Second: micro-optimizing Task.fromJSON to shave 10% off its 103 ms would give you 10 ms, whereas fixing the grouping algorithm will give you 145. That is the order in which you have to work.
But before touching the algorithm it is worth knowing what the engine does with your code, because there are a handful of writing decisions that do matter —and an awful lot that do not.
- How the engine works: parsing, interpretation and JIT
A modern engine (V8 in Chrome and Node, SpiderMonkey in Firefox, JavaScriptCore in Safari) does not interpret your code line by line forever. It does something smarter: it starts fast and improves what gets used a lot.
flowchart TD
A["Source code"] --> B["Parser"]
B -->|"lazy parsing:<br/>the header only"| C["Bytecode<br/>(Ignition)"]
C --> D["Interpreter<br/>starts NOW"]
D -->|"the function is called often:<br/>it turns hot"| E["Optimizing compiler<br/>(Maglev / TurboFan)"]
E --> F["Optimized<br/>machine code"]
F -->|"an assumption fails"| G["Deoptimization"]
G --> D
Four ideas to hold on to:
Parsing is lazy. The engine does not fully parse the body of a function until you are about to run it. It looks at just enough to know where the function ends. That is why a 2 MB JavaScript file costs time even if you execute almost none of it: the whole thing has to be walked. That is an argument for 09-05, not for here, but it explains why "download less code" is an execution optimization as well as a network one.
Execution starts in the interpreter. Bytecode is generated quickly and runs straight away. That favors startup over peak speed.
Hot code gets compiled. When a function runs many times, or a loop iterates a lot, the engine promotes it to an optimizing compiler that generates machine code specialized for the types it has seen so far.
Assumptions can fail. If the optimized code assumed "this parameter is always a number" and one day a string arrives, the engine deoptimizes: it throws the machine code away, goes back to the interpreter and may reoptimize later. Repeated deoptimization inside a hot loop (what is known as a deopt loop) can make a fragment ten times slower without a single line having changed.
And here is the most important practical consequence of the whole section: what you have just read is, for 95% of code, cultural information. It is useful for not writing atrocities and for understanding odd measurements. It is not useful for deciding how to write a filter.
- Hidden shapes, monomorphism and deoptimization
To make property access fast, V8 does not store each object as a dictionary. It assigns every object a hidden shape (hidden class or map) describing which properties it has and in what order, so it can reach task.estimatedHours at a fixed memory offset, as in a compiled language.
Two objects share a shape if they were built with the same properties in the same order:
const a = { id: 1, title: 'Redesign the multipurpose room' };
const b = { id: 2, title: 'Signage for the screen-printing workshop' };
// a and b share a shape: fast access to both
const c = { title: 'Update the bookings website', id: 3 };
// c has ANOTHER shape (different order), even though it has the same keys
const d = { id: 4, title: 'Screen-printing ink inventory' };
d.reviewer = 'Marta'; // adding a property afterwards CHANGES d's shapeWhen a piece of code accesses t.estimatedHours and every object that passes through has the same shape, that access is monomorphic: the engine keeps an inline cache with the offset and goes straight there. If two or three different shapes come through it is polymorphic (still reasonable), and if many do it is megamorphic: the engine gives up and does a generic lookup, which is far slower.
| Situation | Name | Relative cost of one access |
|---|---|---|
| Always the same shape | Monomorphic | 1× |
| 2–4 shapes | Polymorphic | ~1.5–3× |
| More than 4 shapes | Megamorphic | ~10× or more |
The good news is that Nómada Tasks already does the right thing without trying to, and the reason was never performance: it was design. The Task class from 05-02 assigns all its fields in the constructor, always in the same order, and #status is private and only changes through changeStatus. Result: the 600 tasks share a single shape and every access in the render is monomorphic.
Compare with the alternative we would have had if we had used loose object literals:
// ✗ Generates different shapes: four variants depending on the input data
function createLooseTask(data) {
const t = { id: data.id, title: data.title };
if (data.assignee) t.assignee = data.assignee; // shape A or B
if (data.reviewer) t.reviewer = data.reviewer; // shape C or D
return t;
}
// ✓ A single shape for all of them: absent fields exist with a null value
function createStableTask(data) {
return {
id: data.id,
title: data.title,
assignee: data.assignee ?? null,
reviewer: data.reviewer ?? null
};
}The second version is not just faster: it is better code, because the object has a predictable shape and there is no need to check for the existence of properties all over the place. And that is the general rule of this section: the practices that help the engine almost always coincide with the practices that help the reader. When they do not coincide, the reader wins, unless you have a measurement saying otherwise.
One clear warning: do not write strange code to "help the JIT". Engines change every six weeks, and the tricks that circulated in 2012 are counterproductive or irrelevant today. What remains true is the structural stuff: stable types, stable shapes, and never changing the nature of a variable halfway through its life.
- Practical rules for not sabotaging the engine
These are the only five rules in this section worth remembering:
| Rule | Why | In Nómada Tasks |
|---|---|---|
| Initialize every field in the constructor | A single hidden shape | class Task already does it |
| Do not change a variable's type | Avoids deoptimizations | estimatedHours is always a number, never '5' |
| Do not mix types in an array | An array of numbers is a "packed" array; adding an object degrades it | tags are always strings |
| Do not create holes in arrays | arr[1000] = x on an array of 3 turns it sparse and slow |
Use push and array methods |
Avoid delete on objects |
It changes the shape and often degrades to a dictionary | Assign null, or use a copy with rest (04-07) |
And the degraded-array example, which does produce a measurable difference:
// ✗ Sparse array: the engine treats it as a dictionary
const hours = [];
hours[0] = 12;
hours[599] = 5; // 598 holes: slow representation
console.log(hours.length); // 600, but only 2 real elements
// ✓ Packed array of a homogeneous type
const hoursOk = new Array(600).fill(0);
hoursOk[0] = 12;
hoursOk[599] = 5;None of this is going to fix the 480 ms of INP. What is going to fix it is the next section.
- The cost that really rules: complexity
In 02-04 you saw that two nested loops over the same list produce a quadratic cost. Time to formalize it, because it is the only category of optimization that improves things by factors of 50× instead of by 15%.
Complexity describes how the work grows when the data grows, ignoring constants:
| Notation | Name | 6 elements | 600 | 60,000 | Example |
|---|---|---|---|---|---|
| O(1) | Constant | 1 | 1 | 1 | map.get(id), arr[i] |
| O(log n) | Logarithmic | 3 | 10 | 16 | Binary search in a sorted array |
| O(n) | Linear | 6 | 600 | 60,000 | filter, find, reduce, a for |
| O(n log n) | Almost linear | 15 | 6,000 | 960,000 | sort |
| O(n²) | Quadratic | 36 | 360,000 | 3,600,000,000 | A find inside a loop |
| O(2ⁿ) | Exponential | 64 | unfeasible | unfeasible | The allocation simulator from 07-07 |
Look at the quadratic row: going from 6 to 600 tasks —a hundred times more data— multiplies the work by ten thousand. That explains why Nómada Tasks was perfect with the canonical backlog and crawls with the two-year one. It has not become slow: the defect was there from the start and only shows itself at volume.
The warning sign is always the same, and you can spot it at a glance:
// ✗ A loop over tasks, and INSIDE it a search over tasks → O(n²)
for (const task of tasks) {
const related = tasks.filter((other) => shareTag(task, other));
// …
}for is O(n). filter is O(n). One inside the other is O(n²). If while reading a fragment you find a find, filter, includes, indexOf or some inside a loop that walks the same collection, you have found the problem.
- From the quadratic loop to a
Map index
Map indexThis is the real groupByTag() of Nómada Tasks, the one that burns 148 ms:
// js/model/reports.js — O(n²) version
/** For each tag, the tasks that carry it. */
export function groupByTag(tasks) {
const tags = [...new Set(tasks.flatMap((t) => t.tags))];
return tags.map((tag) => ({
tag,
// ✗ This filter walks all 600 tasks... once per tag
tasks: tasks.filter((t) => t.tags.includes(tag))
}));
}With 600 tasks and 6 tags, the filter runs 6 times over 600 elements, and inside each one there is an includes over the task's own tag array. That is 3,600 outer iterations × the cost of the includes. And in the view's real usage it is called by assignee and by tag, which multiplies the nesting.
The solution is the one from 04-05, taken to its extreme: walk once and build an index.
// js/model/reports.js — O(n) version
/**
* For each tag, the tasks that carry it.
* A single pass: each task is visited once, and each of its tags is
* added to a Map in constant time.
*/
export function groupByTag(tasks) {
const index = new Map();
for (const task of tasks) {
for (const tag of task.tags) { // careful: this is NOT quadratic
let list = index.get(tag); // O(1)
if (list === undefined) {
list = [];
index.set(tag, list);
}
list.push(task); // amortized O(1)
}
}
return [...index].map(([tag, tasks]) => ({ tag, tasks }));
}The inner loop does not make this O(n²): it walks the tags of one task, which are one or two, not the 600 tasks. The total complexity is O(n · t) with t ≈ 1.5, that is, linear in practice.
Comparative measurement, using bench() from 09-01 (5 warm-up runs, 15 measured runs, CPU 4×):
| Variant | Median | Min | p95 | Versus the baseline |
|---|---|---|---|---|
Nested filter — O(n²) |
148 ms | 141 ms | 173 ms | 1× |
Map index — O(n) |
3.1 ms | 2.8 ms | 4.4 ms | 48× faster |
Forty-eight times. No micro-optimization on earth produces that. And most important of all: with 6,000 tasks, the quadratic version would cost around 15 seconds and the linear one around 31 ms. The improvement grows with the data.
The same pattern applies to Board.findById, which the delegated controller from 06-04 calls on every click:
// js/model/board.js
export class Board {
#tasks = [];
#byId = new Map(); // index, maintained alongside the array
add(task) {
this.#tasks.push(task);
this.#byId.set(task.id, task); // the index is updated HERE
this.#invalidate();
return this;
}
/** O(1) instead of O(n). */
findById(id) {
return this.#byId.get(id) ?? null;
}
}For a single click the difference is imperceptible (0.004 ms against 0.00008 ms). But when the BoardChannel from 07-04 receives a batch of 600 updates after a reconnection, those 600 linear searches are 360,000 comparisons: 34.2 ms against 0.8 ms. The rule is simple and admits no nuance: if you look something up by key more than once, index it by key.
A word of honesty, however. A Map takes up memory and has to be kept in sync with the array: if somebody removes a task and forgets the delete on the index, you have a data bug and a memory leak (09-03). The index is justified when there are many lookups; with six tasks and one click a minute, find is perfectly fine.
- Computing only once: memoization
The second family of optimizations is not repeating identical work. In 03-04 you built createMemoizedCounter() with a closure; the general pattern is this:
// js/util/memoize.js
/**
* Returns a version of `fn` that stores results already computed.
* Requirements: `fn` must be PURE (same argument → same result, no side effects).
* @param {Function} fn
* @param {Function} [key] How to turn the arguments into a cache key.
*/
export function memoize(fn, key = (...args) => args.join('|')) {
const cache = new Map();
return function (...args) {
const k = key(...args);
if (cache.has(k)) return cache.get(k); // hit: zero work
const value = fn.apply(this, args);
cache.set(k, value);
return value;
};
}And its natural use in Nómada Tasks: the Intl date formatting in util/format.js, which is surprisingly expensive because creating an Intl.DateTimeFormat involves loading locale data.
// js/util/format.js
import { memoize } from './memoize.js';
const formatter = new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' });
/** '2026-09-05' → '5 September 2026' */
export const readableDate = memoize((iso) =>
formatter.format(new Date(`${iso}T00:00:00`))
);With 600 tasks and around 200 distinct dates, memoizing turns 600 formattings into 200: 4.6 ms → 1.7 ms. Not much, but free and risk-free.
Now the three conditions you have to verify before memoizing anything, because memoizing badly produces bugs that are very hard to find:
- The function must be pure. If it depends on something that changes (the time, the board,
Math.random), the cache returns lies. - The key must capture every input.
readableDate(iso)is fine; a function that also depended on the language would need the language in the key. - The cache must have a limit or an invalidation. A
Mapgrowing indefinitely is a textbook memory leak (09-03). For bounded keys —200 dates— there is no problem; for unbounded keys you have to set a cap or use aWeakMap.
Memoizing readableDate satisfies all three. Memoizing summary() fails the first, and that is where the interesting case lies.
- A properly invalidated cache in
Board
BoardBoard.summary(today) walks the tasks to compute total, open, total hours, open hours, overdue and effort. With 600 tasks it costs 1.4 ms. The view calls it three times per render (the summary bar, the sidebar and the exporter), and a live filtering of 23 keystrokes fires 23 renders: 1.4 × 3 × 23 = 96 ms thrown in the bin recomputing exactly the same thing.
summary() is not pure: it depends on the board's internal state. But it is deterministic as long as the board does not change, and that is exactly the condition that permits a cache with explicit invalidation.
The correct technique is a version counter: every mutation increments it, and the cache records which version it was computed with.
// js/model/board.js
export class Board {
#tasks = [];
#byId = new Map();
#version = 0; // incremented on EVERY mutation
#summaryCache = null; // { version, today, value }
// ─────────────── mutations: they all invalidate ───────────────
#invalidate() {
this.#version += 1;
}
add(task) {
this.#tasks.push(task);
this.#byId.set(task.id, task);
this.#invalidate();
return this;
}
removeTask(id) {
const i = this.#tasks.findIndex((t) => t.id === id);
if (i === -1) return false;
this.#tasks.splice(i, 1);
this.#byId.delete(id); // the index, kept in sync
this.#invalidate();
return true;
}
changeStatus(id, next) {
const task = this.findById(id);
if (task === null) throw new DataError(`Task ${id} does not exist`);
task.changeStatus(next); // validates R6 and may throw
this.#invalidate(); // ← if this is missing, the cache lies
return task;
}
// ─────────────── cached reads ───────────────
/**
* Board summary. It is recomputed only if the board has changed
* or if it is requested for a different date.
*/
summary(today = TODAY) {
const c = this.#summaryCache;
if (c !== null && c.version === this.#version && c.today === today) {
return c.value; // hit: O(1)
}
const value = this.#computeSummary(today);
this.#summaryCache = { version: this.#version, today, value };
return value;
}
#computeSummary(today) {
let totalHours = 0, openHours = 0, open = 0, overdue = 0, effort = 0;
for (const t of this.#tasks) { // ONE single pass
totalHours += t.estimatedHours;
effort += t.effort();
if (t.isOpen()) {
open += 1;
openHours += t.estimatedHours;
if (t.isOverdue(today)) overdue += 1;
}
}
// Object.freeze: the cache ALWAYS returns the same object; nobody may mutate it
return Object.freeze({
total: this.#tasks.length, open, totalHours, openHours, overdue, effort
});
}
openHours(today = TODAY) {
return this.summary(today).openHours; // reuses the cache
}
}Five decisions in that code, and they are what separates a correct cache from a generator of subtle bugs:
- The key includes
today. Without it, asking for the summary for a different date would return yesterday's. It is the most common invalidation failure: forgetting a parameter. #invalidate()is in every mutation, includingchangeStatus, which does not touch the array but the insides of a task. If one single mutation path forgets it, the cache lies and the bug will surface days later.- The returned object is frozen. Since the cache always hands out the same reference, if somebody did
r.overdue = 0they would corrupt the state for every future reader.Object.freezeprevents it (and in strict mode, it throws). - One pass instead of six. The previous version chained
filterandreduce; walking once and accumulating everything lowers the cost even on cache misses. openHoursleans onsummary. A single source of truth and a single cache.
And the test, because a cache with no invalidation test is a time bomb (08-03):
// test/model/board-cache.test.js
describe('summary cache', () => {
test('returns the same object while the board does not change', () => {
const board = new Board('Taller Nómada', createBacklog());
expect(board.summary(TODAY)).toBe(board.summary(TODAY)); // toBe: same reference
});
test('it is invalidated when a task changes status', () => {
const board = new Board('Taller Nómada', createBacklog());
expect(board.summary(TODAY).openHours).toBe(45);
board.changeStatus(3, 'in-progress'); // 'Update the bookings website', 14 h
board.changeStatus(3, 'done');
expect(board.summary(TODAY).openHours).toBe(31); // 45 − 14
});
test('it is recomputed for a different date', () => {
const board = new Board('Taller Nómada', createBacklog());
expect(board.summary('2026-09-20').overdue).toBe(1);
expect(board.summary('2026-12-31').overdue).toBe(5); // different date, different value
});
});Result for measurement 6 of the baseline:
| Measurement | Before | After |
|---|---|---|
summary() per call (cache miss) |
1.4 ms | 0.9 ms (one pass instead of six) |
summary() per call (hit) |
1.4 ms | 0.0008 ms |
| Total over a 23-keystroke filtering | 96 ms | 1.5 ms |
- Frequent handlers: debounce and throttle
The 96 ms above were only half the search box's problem. The other half is that there are 23 renders where there should be 2. No computation is as fast as the computation you never run.
You already used debounce in 06-07 and 07-01, and 06-07 warned you not to confuse it with throttle. Here is the full distinction.
flowchart TB
subgraph E["Events: the user types 'screen-printing'"]
direction LR
e1["s"] --- e2["c"] --- e3["r"] --- e4["e"] --- e5["e"] --- e6["..."] --- e7["g"]
end
E --> D["debounce(300)<br/>waits for them to STOP"]
E --> T["throttle(100)<br/>at most 1 every 100 ms"]
D --> D1["1 run<br/>at the end"]
T --> T1["regular runs<br/>during the process"]
- Debounce: postpones execution until N ms have passed without new events. If the user keeps typing, it keeps postponing. It runs once, at the end.
- Throttle: runs at most once every N ms, discarding the calls in between. It runs regularly, throughout.
| Criterion | Debounce | Throttle |
|---|---|---|
| When it runs | When the burst ends | At intervals, during the burst |
| Do the intermediate results matter? | No | Yes |
| Typical cases | Search box, autosave, remote validation, final resize |
scroll, mousemove, pointermove, progress bar |
| Risk | If N is high, it feels slow | It runs more often: you must make each run cheap |
| In Nómada Tasks | Search box filter (300 ms), saving to localStorage (300 ms) |
Sticky header on scroll (100 ms), virtual window position (09-04) |
And the complete implementation in util/time.js, extending the debounce you already had:
// js/util/time.js
/**
* Runs `fn` only once `wait` ms have passed since the LAST call.
* @param {Function} fn
* @param {number} wait
* @returns {Function} with a .cancel() method for cleanup (09-03)
*/
export function debounce(fn, wait = 300) {
let timer = null;
const wrapped = function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
wrapped.cancel = () => clearTimeout(timer); // essential when destroying the view
return wrapped;
}
/**
* Runs `fn` at most once every `interval` ms.
* It runs immediately the first time (leading edge) and one final time
* at the end of the burst (trailing edge), which is almost always what you want.
*/
export function throttle(fn, interval = 100) {
let last = 0;
let pending = null;
const wrapped = function (...args) {
const now = performance.now();
const remaining = interval - (now - last);
if (remaining <= 0) { // the interval has elapsed: run now
clearTimeout(pending);
pending = null;
last = now;
fn.apply(this, args);
} else if (pending === null) { // schedule the trailing-edge run
pending = setTimeout(() => {
last = performance.now();
pending = null;
fn.apply(this, args);
}, remaining);
}
};
wrapped.cancel = () => { clearTimeout(pending); pending = null; };
return wrapped;
}The trailing edge of the throttle is the detail most home-made implementations forget: without it, if the user stops scrolling right after a run, the final position is never processed and the interface is left out of date.
Applied to the controller from 06-04:
// js/view/controller.js
import { debounce, throttle } from '../util/time.js';
// Search box: only the final result matters → debounce
const filterTasks = debounce((text) => view.update({ filters: { text } }), 300);
$('#search').addEventListener('input', (e) => filterTasks(e.target.value));
// Sticky header: the result during the scroll matters → throttle
const updateHeader = throttle(() => {
document.body.classList.toggle('scrolled', window.scrollY > 120);
}, 100);
window.addEventListener('scroll', updateHeader, { passive: true });That { passive: true } is not decorative: it promises the browser that the handler will not call preventDefault(), so it can scroll without waiting for your code to finish. For scroll, touchstart and wheel it is practically mandatory.
Search box measurement with both optimizations (cache + debounce), typing "screen-printing" at normal speed:
| Measurement | Before | With cache | With cache + debounce |
|---|---|---|---|
| Renders triggered | 23 | 23 | 2 |
| Total JavaScript time | 412 ms | 316 ms | 28 ms |
| Measured INP | 480 ms | 372 ms | 96 ms |
INP now meets the ≤ 200 ms target. And notice the order of the columns: the cache alone was not enough, because the dominant problem was not the computation but the render (which 09-04 will fix). The debounce worked because it eliminates entire renders.
One last nuance about scroll: for work that affects what is visible, requestAnimationFrame is usually better than throttle with a fixed number of milliseconds, because it syncs with the screen's real rhythm. That is 09-04's material.
- Chunking long work so the thread is not blocked
05-07 established it: JavaScript has a single thread for running your code and painting the interface; a heavy loop freezes it, and await does not fix that because it does not yield the thread if there is nothing genuinely asynchronous behind it. The solution announced there was chunking.
The pattern consists of splitting the work into batches and handing control back to the browser between batches, so it can service events and paint.
// js/util/batches.js
/**
* Yields the thread to the browser so it can paint and handle events.
* The best API available in the current browser is chosen.
*/
export function yieldThread() {
// 1 · The best option if it exists: yields without losing the task's priority
if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') {
return scheduler.yield();
}
// 2 · Universal fallback: a macrotask (05-07). A microtask will NOT do
return new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* Processes `items` in batches, yielding the thread between them.
* @param {Iterable} items
* @param {Function} work What to do with each item.
* @param {object} options
* @param {number} options.budget ms of work before yielding.
* @param {AbortSignal} options.signal For cancellation (07-03).
* @param {Function} options.onProgress
*/
export async function inBatches(items, work, {
budget = 8, signal, onProgress
} = {}) {
const list = [...items];
let started = performance.now();
for (let i = 0; i < list.length; i += 1) {
signal?.throwIfAborted(); // cooperative cancellation
work(list[i], i);
// Yield when this frame's budget runs out, not every N items:
// that way it adapts by itself to fast and slow machines
if (performance.now() - started >= budget) {
onProgress?.(i + 1, list.length);
await yieldThread();
started = performance.now();
}
}
onProgress?.(list.length, list.length);
}Three important details:
Yield by time, not by item count. if (i % 100 === 0) is what you see everywhere, and it is wrong: on a fast laptop it yields too much (and runs slowly from all the back-and-forth), and on a slow phone it yields too little (and blocks anyway). Measuring the budget adapts by itself.
setTimeout(0) does yield; await Promise.resolve() does not. It is exactly the macrotask/microtask distinction from 05-07: microtasks are drained before the browser paints, so yielding to a microtask lets nothing be painted. This is one of the most frequent mistakes when implementing chunking.
The 8 ms budget comes from section 2 of 09-01: under 10 ms of JavaScript per frame.
And the three ways of yielding, compared:
| API | When the continuation runs | Appropriate use | Watch out |
|---|---|---|---|
setTimeout(fn, 0) |
Next macrotask (real minimum ~4 ms with nesting) | Universal fallback | It competes with other tasks; work can sneak in ahead |
requestAnimationFrame |
Just before the next paint | Visual work (09-04) | It does not run if the tab is hidden |
requestIdleCallback |
When the browser is idle | Non-urgent work: precomputing, sending analytics | It may take a long time, or never arrive if there is activity |
scheduler.yield() |
Straight away, preserving the priority | The modern option for chunking | Availability is still uneven: use a fallback |
requestIdleCallback deserves an example, because it is the right tool for work that can wait:
// Precompute the tag index when the browser has nothing to do
requestIdleCallback((deadline) => {
// deadline.timeRemaining() says how many ms are left in the idle slot
if (deadline.timeRemaining() > 5 || deadline.didTimeout) {
tagIndex = groupByTag([...board]);
}
}, { timeout: 2000 }); // if there is no slot within 2 s, run it anywayApplying inBatches to rebuilding the board after loading 600 tasks:
| Measurement | In one loop | In 8 ms batches |
|---|---|---|
| Longest task | 1,180 ms | 41 ms |
| Total time to finish | 1,180 ms | 1,310 ms |
| Does it respond to a click meanwhile? | No | Yes, in < 50 ms |
| Can progress be shown? | No | Yes |
Read the second row carefully: the total work has increased by 11%, because of the cost of going back and forth through the event loop. And it is still an enormous improvement, because perceived performance is not total time, but the time during which the user can do nothing. This is one of the most counterintuitive lessons of the module.
But chunking has a limit: if the work is 940 ms of pure computation, chunking spreads it out but still steals 940 ms from the thread that has to paint. For that you need another thread.
- Web Workers: a genuinely separate thread
A Web Worker runs JavaScript on a thread separate from the main one. It is not a simulation or a scheduling trick: it is real parallelism, on another core if there is one.
In exchange, it lives in a world apart:
| It has | It does not have |
|---|---|
| Its own thread and its own event loop | Access to the DOM (no document) |
fetch, WebSocket, IndexedDB, caches |
window, localStorage, alert |
postMessage, import of ES modules |
Variables shared with the main thread |
performance, timers, crypto |
Direct access to your objects |
That last row is the key: worker and main thread do not share memory. They communicate by passing messages, and the messages are copied.
flowchart LR
subgraph P["Main thread"]
A["app.js"] --> B["DOM · paint · events"]
end
subgraph W["Worker"]
C["planner.worker.js<br/>heavy computation"]
end
A -->|"postMessage(data)<br/>structured clone"| C
C -->|"postMessage(result)<br/>structured clone"| A
The minimal skeleton:
// js/planning/planner.worker.js
// A module worker can import like any other module (05-04)
import { computeReport } from './report.js';
self.addEventListener('message', (event) => {
const { type, id, data } = event.data;
if (type !== 'report') return;
try {
const result = computeReport(data); // 940 ms of computation… on ANOTHER thread
self.postMessage({ type: 'report:ok', id, result });
} catch (error) {
// Error objects do survive structured cloning, but your own classes do not
self.postMessage({ type: 'report:error', id, message: error.message });
}
});// js/planning/planner-client.js
/** Wraps the worker in a promise API, which is how you want to use it (05-06). */
export class Planner {
#worker = null;
#pending = new Map(); // id → { resolve, reject }
#nextId = 0;
#ensureWorker() {
if (this.#worker !== null) return this.#worker;
this.#worker = new Worker(
new URL('./planner.worker.js', import.meta.url),
{ type: 'module' } // essential in order to use import
);
this.#worker.addEventListener('message', ({ data }) => {
const pending = this.#pending.get(data.id);
if (pending === undefined) return;
this.#pending.delete(data.id); // ← if missing, you have a leak (09-03)
if (data.type === 'report:ok') pending.resolve(data.result);
else pending.reject(new Error(data.message));
});
// Errors from the worker itself (a broken import, an uncaught exception)
this.#worker.addEventListener('error', (e) => {
for (const { reject } of this.#pending.values()) reject(new Error(e.message));
this.#pending.clear();
});
return this.#worker;
}
/** @returns {Promise<object>} the report computed off the main thread. */
report(data) {
const worker = this.#ensureWorker();
const id = this.#nextId += 1;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
worker.postMessage({ type: 'report', id, data });
});
}
/** Explicit cleanup: a live worker retains memory and a thread (09-03). */
destroy() {
this.#worker?.terminate();
this.#worker = null;
this.#pending.clear();
}
}The per-message id is not a luxury: without it you cannot have two requests in flight and match each response with its promise. It is the same problem the id of an HTTP request solves.
The new URL(..., import.meta.url) is mandatory if you use a bundler (09-05): it is the pattern Vite and friends recognize in order to include the worker in the build. A plain string path would not work after bundling.
- The cost of crossing the boundary: serialization and transferables
Here is the workers' trap, and why they sometimes do not pay off. When you call postMessage(data), the browser makes a structured clone of the data —the same algorithm as the structuredClone you saw in 04-08— and hands the copy to the other thread. Copying costs time, and that time is paid on the sending thread.
First, the comparison of copying mechanisms over the 600 tasks (about 182 kB of JSON):
| Mechanism | Median | What it preserves |
|---|---|---|
JSON.parse(JSON.stringify(x)) |
8.4 ms | JSON types only: loses undefined, Date, Map, Set, functions |
structuredClone(x) |
3.1 ms | Date, Map, Set, RegExp, ArrayBuffer, cyclic references |
postMessage (one way) |
3.3 ms | Same as structuredClone |
structuredClone is faster and more faithful: it is the correct answer to "how do I deep-copy", and the JSON round trip from 04-08 is relegated to when you specifically need text (for localStorage or for the network).
One crucial limitation to keep in mind at all times: structured cloning does not preserve classes. A Task object arrives at the worker as a plain object, with no methods and no private fields:
// ✗ This fails in the worker
worker.postMessage({ data: [...board] }); // Task instances
// inside the worker: data[0].isOverdue is not a function
// ✓ Serialize to plain data with the toJSON you already have (05-02), and rebuild if needed
worker.postMessage({ data: [...board].map((t) => t.toJSON()) });Second, transferable objects. Some types —ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas— can be transferred instead of copied: the memory changes owner, with no copy, in practically zero time. The price is that the sender loses access (the buffer becomes detached).
// Transfer a 4.8 MB buffer with the report's numeric data
const buffer = new Float64Array(600 * 5).buffer;
worker.postMessage({ type: 'compute', buffer }, [buffer]); // ← 2nd argument: the list
console.log(buffer.byteLength); // 0 ← it is no longer yoursSending a 4.8 MB ArrayBuffer |
Cost on the sending thread |
|---|---|
Copied (postMessage(buffer)) |
6.2 ms |
Transferred (postMessage(buffer, [buffer])) |
0.08 ms |
The complete operating rule for deciding whether a worker pays off:
A worker pays off when the computation time is far greater than the serialization time. If you are going to compute for 5 ms and serialize 3 ms out and 3 ms back, do not do it. If you are going to compute for 940 ms, do it without hesitation.
- The planning report, in a worker
Taller Nómada's quarterly report groups the 600 tasks by assignee, week and tag, computes workload percentiles and searches for the best distribution of hours. It costs 940 ms on the main thread. While it computes, Marta sees a frozen screen: it does not respond to clicks, it does not scroll, it paints nothing. It is exactly the scenario from 05-07.
With the Planner from section 11:
// js/view/controller.js
import { Planner } from '../planning/planner-client.js';
const planner = new Planner();
$('#generate-report').addEventListener('click', async () => {
const button = $('#generate-report');
button.disabled = true;
button.textContent = 'Calculating…'; // the interface STAYS ALIVE
try {
// toJSON: plain data, because classes do not survive the copy (section 12)
const report = await planner.report([...board].map((t) => t.toJSON()));
showReport(report);
} catch (error) {
showError(`The report could not be generated: ${error.message}`);
} finally {
button.disabled = false;
button.textContent = 'Generate report';
}
});Notice what has changed and what has not: the view code is practically identical to how it would be with an ordinary async function. Wrapping the worker in promises (05-06) means the complexity of message passing is locked inside Planner and does not spread.
Before-and-after measurement (CPU 4×, median of 9 runs, pressing the button and scrolling the list while it computes):
| Measurement | Main thread | Web Worker |
|---|---|---|
| Total time until the report is visible | 940 ms | 1,012 ms (+8%) |
| Main-thread blocking | 940 ms | 52 ms (serializing out + back) |
| Longest task on the main thread | 940 ms | 31 ms |
| INP during the computation | 620 ms | 28 ms |
| Frames dropped while scrolling | 56 | 0 |
| Can it be cancelled? | No | Yes (terminate()) |
The counterintuitive pattern again: total work has increased and the experience has improved radically. Eighty milliseconds of messaging cost in exchange for 888 ms during which the application stops being dead. Plus a new capability that was previously impossible: cancelling, because terminate() genuinely kills the thread, something a loop on the main thread does not allow in any way whatsoever.
And the honest comparison with the module's alternatives, for the same problem:
| Approach | Blocking | Total | Added complexity | Verdict |
|---|---|---|---|---|
| Direct loop | 940 ms | 940 ms | None | Unacceptable |
inBatches with yielding |
41 ms | 1,070 ms | Low | Acceptable if there is no worker |
| Web Worker | 52 ms | 1,012 ms | Medium | The solution |
| WebAssembly (07-07) | 940 ms | 380 ms | High | Does not solve the blocking on its own |
The last row closes the discussion from 07-07: Wasm made the computation 2.5× faster, but it still blocked the main thread, because the problem was never raw speed but who occupies the thread that paints. A worker in ordinary JavaScript beats Wasm on the main thread. And if one day you needed the best of both, a Wasm module inside a worker is a perfectly normal combination.
- Debunked myths
A good part of what people repeat about "fast JavaScript" is folklore from fifteen years ago, when engines had no JIT. Here it is measured, on the reference laptop, over the 600 tasks, with bench() and 15 runs.
| Myth | Measured reality | Verdict |
|---|---|---|
"for is much faster than forEach/map" |
0.038 ms against 0.061 ms over 600 elements: 23 microseconds of difference | Irrelevant. Write whatever reads better |
"++i is faster than i++" |
Identical; the compiler generates the same thing when you do not use the value | False |
"Concatenating strings with + is slow; use array.join" |
True in 2005. Today engines optimize concatenation with ropes: 600 concatenations, 0.09 ms against 0.11 ms | False today |
"delete is the same as assigning undefined" |
delete changes the hidden shape and can degrade the object to a dictionary |
True that they differ, and delete is worse |
| "Local variables are faster than globals" | True, but the difference is nanoseconds except in very hot loops | Irrelevant without a measurement |
"try/catch prevents optimization" |
True until about 2015. Today TurboFan optimizes functions with try/catch without trouble |
Obsolete |
| "Arrow functions are faster" | Identical at run time; the difference is semantic (this), not speed |
False |
"You should cache array.length in the for" |
The engine has done it by itself for over a decade | Obsolete |
"Map is always faster than an object" |
For string keys and few entries, an object can win. Map wins with frequent insertion/deletion and non-string keys |
It depends: measure |
| "Fewer lines means faster" | No relationship whatsoever | False |
The practical conclusion is not "nothing matters": it is that the axis that matters moved. You no longer gain anything by choosing between for and forEach; you gain 48× by choosing between O(n²) and O(n), 130× by not repeating a computation, and all of the perceived performance by deciding who occupies the main thread.
Readability wins by default. Only a concrete measurement, over real data, justifies writing less clear code. And when you do, leave a comment with the number that justified it and the date, because in two years the engine will have changed.
Common Mistakes and Tips
- Micro-optimizing before looking at the complexity. Swapping
forEachforforin a quadratic algorithm is painting a wall that is falling down. - Not spotting the
findinside the loop. It is the number one cause of slowness at volume, and it is visible at a glance once you know how to look for it. - Memoizing an impure function. If it depends on the board, the clock or randomness, the cache returns lies and the bug will show up a long way from its cause.
- Forgetting an invalidation path.
changeStatusdoes not touch the task array, but it changes the result ofsummary. If it does not invalidate, the cache lies. - Leaving the cache key incomplete. Forgetting
todayin the summary key is a data bug, not a performance one, and one of the kind that takes weeks to discover. - Caches with no limit. A
Mapthat grows with every new key is a leak (09-03). - Confusing debounce with throttle. Debounce on
scrollleaves the interface frozen until the user stops; throttle on a search box fires requests halfway through a word. - Setting a
debouncethat is too long. Above about 400 ms it is perceived as slowness. 250–300 ms is the usual range for a search box. - Creating the debounced function inside the handler. An
inputwithdebounce(fn, 300)created on every event debounces nothing: each call has its own timer. Create it once, outside. - Yielding with
await Promise.resolve(). That is a microtask: it runs before painting. It yields nothing (05-07). - Chunking by item count instead of by time. It does not adapt to different machines. Measure the budget.
- Putting a 5 ms computation in a worker. Serialization will cost more than the computation.
- Sending class instances through
postMessage. They arrive as plain objects, with no methods. UsetoJSON(). - Creating a worker for every operation. Starting one costs between 10 and 40 ms. Reuse one, or a small pool.
- Not calling
terminate(). A live worker holds on to its thread and all its memory (09-03). - Tip: before optimizing a computation, ask whether it needs doing at all. The fastest computation is the one that never runs: cache, debounce or load it on demand (09-05).
- Tip: write a test that pins the complexity. Checking that the time with 1,200 tasks is less than triple the time with 600 catches a regression back to quadratic.
- Tip: always wrap the worker in a promise API. Message passing is an implementation detail that must not contaminate the view.
Exercises
Exercise 1 — Hunting the quadratic. This code computes, for each task, how many others share its assignee and priority. With 600 tasks it takes 214 ms.
export function workloadPeers(tasks) {
return tasks.map((t) => ({
id: t.id,
peers: tasks.filter((o) =>
o.id !== t.id && o.assignee === t.assignee && o.priority === t.priority
).length
}));
}State (a) the current complexity and how many comparisons it makes with 600 tasks, (b) an O(n) rewrite using Map, (c) what complexity the new version has, and (d) what improvement you would expect if the board grew to 6,000 tasks.
Exercise 2 — A cache invalidated per assignee. Board needs workloadOf(assignee), returning that person's open hours (the canonical ones: Iván 25 h, Lucía 14 h, Marta 6 h). It is called once per card during the render, that is, 600 times. Write a version with a properly invalidated cache, state what has to be part of the key, write the invalidation tests that guarantee it does not lie, and calculate the expected improvement knowing that one computation costs 0.9 ms.
Exercise 3 — Worker, batches or nothing? For each of these four pieces of Nómada Tasks work, choose between "directly on the main thread", "inBatches with yielding" and "Web Worker", justifying it with the criterion of serialization cost versus computation cost. Data: the serialized board is 182 kB, whose cloning costs 3.3 ms per trip.
- Validating the new-task form (10 rules, 0.05 ms).
- Rebuilding 600
Taskinstances fromlocalStorageat startup (103 ms). - Computing the quarterly report (940 ms).
- Recomputing the summary after every status change (0.9 ms).
Solutions
Solution 1
(a) It is O(n²). The map walks the 600 tasks and, for each one, the filter walks all 600: 360,000 comparisons, each with three conditions. Hence the 214 ms.
(b) The key observation: the filter always looks for the same thing, the assignee + priority combination. You can count once how many tasks there are of each combination and then look it up in constant time.
export function workloadPeers(tasks) {
// 1 · One pass: count how many tasks there are per combination → O(n)
const counts = new Map();
for (const t of tasks) {
const key = `${t.assignee}|${t.priority}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
// 2 · Another pass: look up the count and subtract yourself → O(n)
return tasks.map((t) => ({
id: t.id,
peers: counts.get(`${t.assignee}|${t.priority}`) - 1
}));
}The - 1 replaces the original's o.id !== t.id: each task has counted itself.
(c) O(n): two full passes and O(1) accesses to the Map. Measured: 214 ms → 4.2 ms, about 51× faster.
(d) With 6,000 tasks, the quadratic version would make 36 million comparisons: around 21 seconds (100× the 214 ms, because the work grows with the square). The linear one would make 12,000 operations: around 42 ms (10× the 4.2 ms). The improvement would go from 51× to about 500×. That is the essential point about complexity: the benefit of fixing it grows with the data, whereas the benefit of a micro-optimization stays where it is.
Solution 2
The key must include the assignee and today (because "open" does not depend on the date, but if tomorrow we wanted to filter by expiry it would; including it from the start avoids the classic bug), and the whole cache must be invalidated by the board's version counter.
// js/model/board.js
export class Board {
#version = 0;
#workloadCache = { version: -1, today: null, values: new Map() };
/** A person's open hours. Cached per assignee. */
workloadOf(assignee, today = TODAY) {
const c = this.#workloadCache;
// A change of version or of date invalidates the WHOLE map, not one entry
if (c.version !== this.#version || c.today !== today) {
c.version = this.#version;
c.today = today;
c.values = new Map();
}
if (c.values.has(assignee)) return c.values.get(assignee);
let hours = 0;
for (const t of this.#tasks) {
if (t.assignee === assignee && t.isOpen()) hours += t.estimatedHours;
}
c.values.set(assignee, hours);
return hours;
}
}Emptying the whole Map when the version changes is the right thing to do: a mutation can affect any assignee (a change of assignee affects two), so invalidating just one entry would leave stale data. And since the number of assignees is bounded, the Map does not grow without limit.
// test/model/board-workload.test.js
describe('workloadOf with cache', () => {
let board;
beforeEach(() => { board = new Board('Taller Nómada', createBacklog()); });
test('the canonical values', () => {
expect(board.workloadOf('Iván')).toBe(25);
expect(board.workloadOf('Lucía')).toBe(14);
expect(board.workloadOf('Marta')).toBe(6);
expect(board.workloadOf('Iván') + board.workloadOf('Lucía') + board.workloadOf('Marta'))
.toBe(board.summary(TODAY).openHours); // 45
});
test('it is invalidated when a task is completed', () => {
expect(board.workloadOf('Iván')).toBe(25);
board.changeStatus(6, 'in-progress'); // carpentry, 5 h, Iván's
board.changeStatus(6, 'done');
expect(board.workloadOf('Iván')).toBe(20); // 25 − 5
});
test('it is invalidated for ALL assignees, not just the affected one', () => {
board.workloadOf('Marta'); // seeds the cache
board.add(new Task({ ...VALID_DATA, id: 99, assignee: 'Marta',
estimatedHours: 4, status: 'pending' }));
expect(board.workloadOf('Marta')).toBe(10); // 6 + 4
expect(board.workloadOf('Iván')).toBe(25); // still correct
});
test('warm cache: the second call does not recompute', () => {
const t0 = performance.now(); board.workloadOf('Iván'); const cold = performance.now() - t0;
const t1 = performance.now(); board.workloadOf('Iván'); const warm = performance.now() - t1;
expect(warm).toBeLessThan(cold);
});
});Expected improvement: the render calls workloadOf 600 times, but there are only three assignees. Without a cache: 600 × 0.9 ms = 540 ms. With a cache: 3 computations + 597 hits ≈ 2.7 ms. About 200× faster, and —what really matters— it goes from dominating the render to being invisible in it.
Solution 3
| # | Work | Computation | Serialization | Decision | Justification |
|---|---|---|---|---|---|
| 1 | Validating the form | 0.05 ms | — | Directly on the main thread | 0.05 ms is invisible; a worker would cost 130× more in messaging alone |
| 2 | Rebuilding 600 Tasks |
103 ms | 6.6 ms | inBatches |
It blocks too long (103 ms > 50 ms), but the result is class instances, which do not survive the copy: they would have to be rebuilt on the main thread anyway, and the worker would save nothing |
| 3 | Quarterly report | 940 ms | 6.6 ms | Web Worker | Computation 142× larger than the serialization, and the result is plain data. A textbook case |
| 4 | Recomputing the summary | 0.9 ms | 3.3 ms | Directly on the main thread (with a cache) | The outbound serialization alone costs almost four times the entire computation. Here the right optimization is the cache from section 8, not another thread |
The general criterion, summed up in one sentence: if the computation does not comfortably exceed about 50 ms, do not leave the main thread; if it does exceed that but the result does not travel well, chunk it; if it exceeds that and travels well, use a worker. And notice that in two of the four cases the right answer is not "parallelize" but "do not compute": number 4 is solved by caching and number 1 is not solved because it is not a problem.
Conclusion
You have attacked the first front of the baseline and the numbers confirm it: the search box's INP has dropped from 480 ms to 96 ms, the 96 ms of recomputation are now 1.5 ms, groupByTag has gone from 148 ms to 3.1 ms, the long startup task from 1,180 ms to 41 ms through chunking, and the report that froze the screen for 940 ms now occupies the main thread for just 52 ms. All of it without touching a single line of DOM.
You know how the engine works —lazy parsing, bytecode that starts immediately, compilation of hot code, and deoptimization when an assumption fails— and you know about hidden shapes and the difference between monomorphic, polymorphic and megamorphic accesses. With that you have understood why class Task, designed in 05-02 with clarity in mind, turns out to be the fastest form too: every field in the constructor, in the same order, stable types. And you have received the warning that saves years of silliness: do not write strange code to help the JIT; write stable, readable code, which is the same thing.
You are crystal clear about the axis that really rules: complexity. You recognize the sign —a find, filter or includes inside a loop over the same collection— and you know how to turn O(n²) into O(n) with a Map index: 48× in groupByTag, 43× when applying a batch of 600 updates, with the improvement growing as the data grows. And you know the price of the index: memory and the obligation to keep it in sync on every mutation.
You know how to not repeat work: memoizing pure functions with the three conditions verified (purity, complete key, size limit), and putting in a cache invalidated by a version counter when the function is not pure but is deterministic between mutations —with today in the key, #invalidate() on every mutation path, the result frozen so nobody can corrupt it, and invalidation tests that stop the cache from lying. You know how to not run too often with debounce (waiting for the events to stop: search box, autosave) and throttle (running at intervals during the burst: scroll, pointermove), with its trailing edge and its { passive: true }.
And you know how to get off the main thread: chunking with inBatches, yielding on a time budget rather than an item count, distinguishing macrotasks that do yield from microtasks that do not (05-07), with requestIdleCallback for what can wait and scheduler.yield() where it is available; and, when that is not enough, a Web Worker wrapped in a promise API, with its per-message id, its error handling and its destroy(). You know the cost of the boundary: structured cloning is faster and more faithful than the JSON round trip from 04-08, but it does not preserve classes, and there are transferables that change owner instead of being copied. Hence the deciding rule: a worker pays off when the computation comfortably exceeds the serialization. That also closes the comparison from 07-07: WebAssembly sped up the computation but still blocked; the worker does not block, which was the real problem.
Finally, you have the table of myths dismantled with measurements: for versus forEach is 23 microseconds, ++i is identical to i++, concatenating strings is no longer slow, try/catch no longer prevents optimization and caching .length has been unnecessary for a decade. Readability wins by default, and only a concrete measurement over real data justifies otherwise.
Three fronts remain. And there is one that has been hinted at several times without being developed: talking about the index Map we said "if you forget the delete, you have a leak"; talking about memoization, "a cache with no limit is a leak"; talking about debounce, "you need .cancel() for cleanup"; and talking about the worker, "a live worker holds on to its thread and all its memory". Four warnings, one single subject. Row 8 of the baseline is still untouched: 37.7 MB retained after 200 filterings, memory the application reserves and never gives back, until the tab turns sluggish and eventually dies. That is a memory leak, and finding it requires understanding how the browser decides what to keep and what to throw away. That is the subject of Memory Management.
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
