JavaScriptWeb DevelopmentProgramming Fundamentals

Four JavaScript fundamentals I make every student prove

Scope, `this`, coercion, and the event loop — the four things that trip up working developers, with concrete examples.

WebPrims Team8 September 20265 min read
Four JavaScript fundamentals I make every student prove

Every batch of junior developers, someone asks why for (var i = 0; i < 3; i++) with a setTimeout inside logs 3 three times. Every single batch. The answer is scope, and it's the first of four fundamentals we make students prove before we let them near a framework. These four are not the whole language, but they are the parts that cause the most confusing bugs in real projects.

Scope and closures: the loop variable trap

The var keyword has function scope, not block scope. When you write var i inside a for loop, the variable belongs to the whole enclosing function, and the loop keeps overwriting a single binding. A closure — the arrow function inside setTimeout — captures the variable itself, not its value at the time of creation. By the time the timeout fires, the loop has finished and i sits at its final value.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// logs 3, 3, 3

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// logs 0, 1, 2

The second loop works because let creates a fresh binding for each iteration. The language gives you a new i every time the body runs, so each closure captures its own copy. This is the difference between capturing a variable and capturing a value. We tell students: if you don't understand why the first loop logs 3 three times, you don't understand closures, and you'll write subtle bugs in event handlers and loops for years.

One more thing about scope: const does not mean immutable. It means the binding cannot be reassigned. A const object can still have its properties changed. Students are surprised by this every June, and the surprise is fair because the name is misleading. const is about the reference, not the content.

What this actually means

this is not about where a function is written. It is about how the function is called. There are four rules, and most bugs come from mixing them up.

First, a method call: counter.tick() sets this to counter. Second, a plain call: tick() sets this to undefined in a module, or to the global object in non-strict mode. Third, new tick() creates a fresh object and sets this to it. Fourth, tick.call(obj) and tick.apply(obj) set this explicitly, and tick.bind(obj) returns a new function with this permanently fixed.

The bug we see most often is a student taking a method out of its object:

const counter = {
  count: 0,
  tick() {
    this.count++;
  }
};

const loose = counter.tick;
loose(); // TypeError: Cannot read property 'count' of undefined

The function loose is called as a plain function, so this is undefined. The fix is to keep the method attached, or to bind it: const loose = counter.tick.bind(counter). Arrow functions change the rules entirely. They ignore this from the call site and use the this of the surrounding scope. That makes them handy for callbacks, but confusing in object methods that need their own this. If an arrow function is used as a method, this still refers to the outer scope, which is almost never what you want.

Coercion and equality: why == lies

JavaScript wants to be helpful, so the loose equality operator == tries to convert both sides to a common type before comparing. That helpfulness produces results that look like bugs. 0 == false is true. "" == 0 is true. null == undefined is true. The strict operator === skips the conversion, so 0 === false is false and null === undefined is false.

The practical rule is simple: use === everywhere, and treat == as a deliberate tool for the rare case where you want coercion. Even then, be explicit about what you expect.

Then there is NaN. It is the only value in JavaScript that is not equal to itself. NaN === NaN is false. When you check if a calculation failed, result === NaN will never work. Use Number.isNaN(result), or Object.is(result, NaN) if you want to be precise about negative zero and other edge cases. Object.is is the closest thing JavaScript has to a mathematically honest equality check, and few developers use it.

The event loop and why await doesn't block

async and await look like they stop the program. They don't. An await suspends the current function and returns control to the event loop, which goes and runs other work. When the awaited promise resolves, the continuation is scheduled as a microtask and runs after the current call stack empties.

async function slow() {
  console.log("start");
  await Promise.resolve();
  console.log("end");
}

slow();
console.log("immediately after");
// logs: start, immediately after, end

The order catches people off guard. The function prints "start", then hits await and yields. The next line, console.log("immediately after"), runs before the continuation. Only after the call stack is empty does the microtask run and print "end".

This matters in the browser. A long synchronous loop — say, ten million iterations — will freeze the page, because the event loop cannot process clicks or repaint until the stack clears. Marking a loop async does not move it off the main thread. We had a student in week three build a search box that ran a heavy filter directly in the input handler. The page froze for two seconds on every keystroke. The fix was to chunk the work or move it to a background worker, not to add await.

The same logic explains why Promise.all is faster than a sequence of await calls. Each await introduces a suspension point, and the event loop may interleave other microtasks between them. Running promises in parallel with Promise.all lets them resolve concurrently, and the total time is the slowest promise, not the sum.

Common questions

Why does [] == ![] return true?

The ! operator converts the array to a boolean, and ![] is false. Then [] == false converts the array to an empty string, and "" == false converts both to 0, so 0 == 0 is true. It is a long chain of coercions, and the only lesson is to use === so the chain never starts.

If await doesn't block the thread, how do I run CPU-heavy work without freezing the UI?

await only helps with I/O and other waiting. CPU-heavy loops still occupy the main thread. Use a background worker with Worker in the browser, or a separate process in Node.js, and have it send the result back as a message.

Is JavaScript compiled or interpreted?

Modern engines compile JavaScript to machine code just before running it, a process called just-in-time compilation. The line between interpreted and compiled

Come and sit a class before you decide

Reading about it only gets you so far. Pick a day, sit in on a class that is already running, write some code, and talk to the students in it. Free, and nothing to pay afterwards unless you want to join.

JavaScriptWeb DevelopmentProgramming Fundamentals

Found this useful?

Share it with someone who is learning.