JavaScript Fundamentals
Key Points
- JS is single-threaded with an event loop. No thread-pool magic;
awaitschedules 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. thisis dynamic in regular functions, lexical in arrow functions. Five binding rules: default, implicit, explicit (call/apply/bind),new, arrow.- Prefer
const/let; nevervar.varis function-scoped and hoisted-with-undefined;let/constare block-scoped and hoisted-into-the-TDZ. ===not==.==triggers coercion rules nobody remembers correctly.NaN !== NaN— useNumber.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
functiondeclarations: fully hoisted (callable above the declaration).var: hoisted, initialized toundefined.let/const: hoisted into the Temporal Dead Zone — referencing throwsReferenceErroruntil 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.
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."" == 0istrue.null == undefinedistrue. Use===.NaN !== NaN. UseNumber.isNaN(x).isNaN(x)(noNumber.) 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 likedate-fns/ Luxon.- Object/array references.
const o = {a:1}; const p = o; p.a = 2;mutatesotoo.constfreezes the binding, not the value. - Floating-point.
0.1 + 0.2 === 0.3isfalse. Same as .NETdouble. Usedecimal-equivalent libs (big.js, BigInt for integers) for money. - Truthy/falsy gotchas.
[]is truthy.{}is truthy."0"is truthy.0is falsy.""is falsy. for...initerates keys (and inherited ones);for...ofiterates values of iterables. They are not interchangeable.forEachdoesn't await. Insidearr.forEach(async ...), awaits don't gate the loop — usefor...ofwithawait, orPromise.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)
❌ 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
❌ 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
Design patterns for this topic
Pattern 1 — "Module pattern via closures"
- Intent: encapsulate private state behind a public API without classes.
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 JSCancellationToken).
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
- ⚠️
varin loops with async — single shared binding, all callbacks see the final value. - ⚠️
thislost when passing methods as callbacks — bind, use arrows, or wrap. - ⚠️
NaN !== NaN— onlyNumber.isNaNworks. - ⚠️
typeof null === "object"— historical bug. - ⚠️ Floating-point money — same trap as
doublein C#. - ⚠️
forEachignores async — silently broken. - ⚠️ Mutating
Date—set*methods mutate in place. - ⚠️ CommonJS in browser code — won't run without a bundler shim.