Skip to content

JavaScript Fundamentals

Key Points

  • JS is single-threaded with an event loop. No thread-pool magic; await schedules continuations onto the microtask queue, not a worker.
  • Closures + lexical scoping are the model. Every function (and arrow) captures the surrounding scope by reference — not by value. This is where most C#-dev "wait, what?" moments live.
  • this is dynamic in regular functions, lexical in arrow functions. Five binding rules: default, implicit, explicit (call/apply/bind), new, arrow.
  • Prefer const/let; never var. var is function-scoped and hoisted-with-undefined; let/const are block-scoped and hoisted-into-the-TDZ.
  • === not ==. == triggers coercion rules nobody remembers correctly. NaN !== NaN — use Number.isNaN.
  • ESM is the standard. CommonJS (require) is legacy/Node-only. Modern bundlers and Node ≥20 speak ESM natively.

Concepts (deep dive)

The execution model

┌────────────────────────────────────────┐
│            Call Stack (sync)           │
└────────────────────────────────────────┘
          │ pops frame, then…
┌─────────────────────┐    ┌─────────────────────┐
│  Microtask Queue    │ →→ │   Macrotask Queue   │
│  (Promise.then,     │    │  (setTimeout, I/O,  │
│   queueMicrotask,   │    │   UI events)        │
│   await continuation│    │                     │
└─────────────────────┘    └─────────────────────┘
   drained FULLY before     one task per loop tick
   the next macrotask

C# analogue: roughly like SynchronizationContext + TaskScheduler.Default, but single-threaded. There is no parallelism within one realm. Web Workers give you separate isolates, not shared-memory threads (except via SharedArrayBuffer, rarely used).

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Output: 1, 4, 3, 2  — microtasks drain before the next macrotask.

Closures

Every function captures its surrounding lexical scope. Closures are how privacy, currying, and module-level caches all work.

function counter() {
  let n = 0;
  return () => ++n;     // captures `n` by reference
}
const c = counter();
c(); c(); c();          // 3

💡 The var + closure-in-loop bug is the canonical interview trap:

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// 3, 3, 3 — `var` is one binding shared across iterations.

for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
// 0, 1, 2 — `let` creates a fresh binding per iteration.

Hoisting

  • function declarations: fully hoisted (callable above the declaration).
  • var: hoisted, initialized to undefined.
  • let/const: hoisted into the Temporal Dead Zone — referencing throws ReferenceError until the line of declaration.
  • class: hoisted into TDZ (declaration is not initialization).

this binding

Call form this is
f() undefined (strict) / globalThis (sloppy)
o.f() o
f.call(x) / f.apply(x) x
new F() the new instance
() => ... inherited from enclosing lexical scope (cannot be re-bound)
class Btn {
  constructor() { this.label = "OK"; }
  // Arrow → captures `this` lexically. Safe to pass as event handler.
  onClick = () => console.log(this.label);
}

⚠️ Don't write methods as arrow class fields unless you actually need lexical this — they live on the instance, not the prototype, breaking subclass super.method() calls and inflating per-instance memory.

Prototypal inheritance

JS objects have a hidden [[Prototype]] link (Object.getPrototypeOf(o)). Property lookup walks the chain. class is sugar over prototypes — there are no real classes at runtime.

   instance ──[[Prototype]]──▶ Foo.prototype ──[[Prototype]]──▶ Object.prototype ──▶ null
class Animal { speak() { return "..."; } }
class Dog extends Animal { speak() { return "woof"; } }
const d = new Dog();
d.speak();                                 // "woof"
Object.getPrototypeOf(d) === Dog.prototype // true

Modules: ESM vs CommonJS

ESM (import/export) CommonJS (require)
Syntax import x from "./m.js" const x = require("./m")
Resolution Static (compile-time) Dynamic
Top-level await Yes No
Default in browsers Yes No
Default in Node ≥20 Yes ("type": "module") Legacy
Tree-shakable Yes Limited
// Static import — bundlers can tree-shake
import { add } from "./math.js";

// Dynamic import — code-splits, returns a Promise
const { heavy } = await import("./heavy.js");

// Top-level await (ESM only)
const config = await fetch("/config.json").then(r => r.json());

Promises and async/await

async function load(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    console.error(err);
    throw err;
  }
}

async functions always return a Promise. await suspends and posts a continuation to the microtask queue. There is no ConfigureAwait because there is no thread to marshal back to.

ES2020+ features worth knowing

// Optional chaining
const city = user?.address?.city;          // undefined if any link is null/undefined

// Nullish coalescing — only triggers on null/undefined (NOT 0 or "")
const port = config.port ?? 8080;

// Logical assignment
opts.timeout ??= 30_000;                   // assign only if null/undefined
flags.debug ||= isDev();                   // assign if falsy
state.dirty &&= !justSaved;                // assign if truthy

// Numeric separators
const big = 1_000_000;

// BigInt
const b = 9007199254740993n;               // beyond Number.MAX_SAFE_INTEGER

// structuredClone — deep clone, replaces the JSON.parse(JSON.stringify(x)) hack
const copy = structuredClone(original);

// at() — negative indexing
[1,2,3].at(-1);                            // 3

// Array.prototype.findLast / findLastIndex
[1,2,3,4].findLast(x => x % 2);            // 3

Strict mode

"use strict" (auto-on in ESM and class bodies): - this is undefined instead of the global object in unbound calls. - Assigning to undeclared variables throws. - Duplicate parameter names throw. - with is forbidden.

You almost never write "use strict" manually anymore — modules and classes give it to you.

Common pitfalls .NET devs hit

  • == does coercion. "" == 0 is true. null == undefined is true. Use ===.
  • NaN !== NaN. Use Number.isNaN(x). isNaN(x) (no Number.) coerces first and is broken for non-numbers.
  • typeof null === "object". Forty-year-old bug, kept for compat.
  • new Date() is mutable. d.setMonth(...) mutates in place. Prefer Temporal (Stage 3) or libraries like date-fns / Luxon.
  • Object/array references. const o = {a:1}; const p = o; p.a = 2; mutates o too. const freezes the binding, not the value.
  • Floating-point. 0.1 + 0.2 === 0.3 is false. Same as .NET double. Use decimal-equivalent libs (big.js, BigInt for integers) for money.
  • Truthy/falsy gotchas. [] is truthy. {} is truthy. "0" is truthy. 0 is falsy. "" is falsy.
  • for...in iterates keys (and inherited ones); for...of iterates values of iterables. They are not interchangeable.
  • forEach doesn't await. Inside arr.forEach(async ...), awaits don't gate the loop — use for...of with await, or Promise.all(arr.map(...)).

Code: correct vs wrong

❌ Wrong: var in a loop with async work

for (var i = 0; i < items.length; i++) {
  fetch(`/api/${items[i]}`).then(() => console.log(i));
}
// Logs items.length each time — single shared `i`.

✅ Correct: let (or for...of)

for (const item of items) {
  fetch(`/api/${item}`).then(() => console.log(item));
}

❌ Wrong: lost this

class Api {
  base = "/v1";
  url(path) { return this.base + path; }
}
const a = new Api();
const f = a.url;
f("/x");   // TypeError — `this` is undefined

✅ Correct: bind, arrow, or call through the object

const f = a.url.bind(a);
f("/x");                  // "/v1/x"

❌ Wrong: forEach with async

[1,2,3].forEach(async id => { await save(id); });
console.log("done");      // Logs before saves complete.

✅ Correct: for...of or Promise.all

for (const id of [1,2,3]) await save(id);     // sequential
await Promise.all([1,2,3].map(save));         // parallel

❌ Wrong: == checks

if (input == null) { /* matches null AND undefined AND 0... no, just null/undefined */ }
if (count == 0) { /* matches "" and false too */ }

✅ Correct: === plus an explicit nullish guard

if (input === null || input === undefined) ...
if (count === 0) ...

Design patterns for this topic

Pattern 1 — "Module pattern via closures"

  • Intent: encapsulate private state behind a public API without classes.
const Counter = (() => {
  let n = 0;
  return { inc: () => ++n, value: () => n };
})();

Pattern 2 — "Debounce / throttle"

  • Intent: rate-limit handlers (search-as-you-type, resize).
const debounce = (fn, ms) => {
  let t;
  return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
};

Pattern 3 — "Memoization"

  • Intent: cache pure-function results.
const memo = fn => {
  const cache = new Map();
  return (k) => cache.has(k) ? cache.get(k) : (cache.set(k, fn(k)), cache.get(k));
};

Pattern 4 — "Pub/sub event bus"

  • Intent: decouple components without a framework.
const bus = (() => {
  const subs = new Map();
  return {
    on(ev, fn) { (subs.get(ev) ?? subs.set(ev, new Set()).get(ev)).add(fn); },
    emit(ev, x) { subs.get(ev)?.forEach(f => f(x)); },
  };
})();

Pattern 5 — "AbortController for cancellation"

  • Intent: cancel in-flight fetch (the JS CancellationToken).
const ac = new AbortController();
fetch("/slow", { signal: ac.signal }).catch(e => e.name === "AbortError" && ...);
ac.abort();

Pros & cons / trade-offs

Aspect Pros Cons
Single-threaded Simple memory model, no locks One slow synchronous loop blocks the UI
Dynamic typing Fast prototyping Easy to ship type bugs; pairs with TS
Prototypal inheritance Flexible Confusing for class-OO devs
ESM Static, tree-shakable Async-by-design (no sync require in browsers)
Closures Powerful encapsulation Memory leaks if you capture huge objects unnecessarily

When to use / when to avoid

  • Use plain JS for tiny scripts, build tooling, or where adding TS adds friction without value.
  • Use modern ES features freely — target ≥ES2020 unless you support legacy IE-tier browsers (you don't).
  • Avoid var. Avoid ==. Avoid CommonJS in greenfield code.
  • Avoid big-int arithmetic for money — use a decimal lib.

Interview Q&A

Q1. Difference between var, let, const? var: function-scoped, hoisted with undefined. let: block-scoped, TDZ. const: block-scoped, TDZ, binding is read-only (value can still mutate).

Q2. What is the event loop? Single-threaded scheduler. After each synchronous task, drains the microtask queue (promises) entirely, then picks one macrotask (timer, I/O, UI event), repeats.

Q3. Microtask vs macrotask? Microtasks: Promise.then, queueMicrotask, await continuations — drained fully each tick. Macrotasks: setTimeout, I/O, message events — one per tick.

Q4. How does this work? Default → undefined/global; implicit o.f()o; explicit f.call(x)x; new F() → new instance; arrow → lexical (cannot be rebound).

Q5. Closure? A function plus its captured lexical scope. Lets inner functions access outer variables after the outer function returns.

Q6. == vs ===? == performs type coercion; === doesn't. Always prefer ===.

Q7. ESM vs CommonJS? ESM is static, tree-shakable, async-only, the web/modern standard. CJS is dynamic, sync, Node-legacy.

Q8. null vs undefined? undefined: variable not assigned. null: explicit "no value". They are == to each other but not ===.

Q9. What does ?. do? Optional chaining — short-circuits to undefined if any link in the chain is null or undefined.

Q10. Difference between ?? and ||? ?? only triggers on null/undefined. || triggers on any falsy (0, "", false, NaN).

Q11. Why doesn't arr.forEach(async ...) await? forEach ignores the returned promise. Use for...of with await, or Promise.all(arr.map(...)).

Q12. How do you cancel a fetch? AbortController — pass signal to fetch, call controller.abort(). The promise rejects with AbortError.


Gotchas / common mistakes

  • ⚠️ var in loops with async — single shared binding, all callbacks see the final value.
  • ⚠️ this lost when passing methods as callbacks — bind, use arrows, or wrap.
  • ⚠️ NaN !== NaN — only Number.isNaN works.
  • ⚠️ typeof null === "object" — historical bug.
  • ⚠️ Floating-point money — same trap as double in C#.
  • ⚠️ forEach ignores async — silently broken.
  • ⚠️ Mutating Dateset* methods mutate in place.
  • ⚠️ CommonJS in browser code — won't run without a bundler shim.

Further reading