JavaScript

JavaScript is the only language that runs natively in every browser. It’s also one of the most misunderstood. Most frustration with JS comes from not knowing how a few core mechanics actually work — once you do, the “weird” stuff makes sense.


🟢 Junior

Types and the lies typeof tells

Everything in JavaScript is either a primitive or an object. Primitives — string, number, bigint, boolean, undefined, symbol, null — are values. Objects are everything else: arrays, functions, dates, your own classes.

typeof is how you’d expect to check this, but it has a famous lie:

typeof null // "object" — always has been, always will be
typeof []   // "object" — arrays are objects
typeof function(){} // "function" — special case

typeof null === 'object' is a 30-year-old bug that’ll never be fixed because too much code depends on it. To check for null, use === null. To check if something is an actual non-null object, use value !== null && typeof value === 'object'.

var is dead, long live let/const

var is function-scoped, meaning it leaks out of if/for blocks and gets hoisted to the top of the function in an initialized-but-undefined state. This caused decades of bugs. let and const are block-scoped and don’t hoist usably.

if (true) {
  var x = 1;
  let y = 2;
}
console.log(x); // 1 — leaked
console.log(y); // ReferenceError

Use const by default. Use let when you need to reassign. var has no place in new code.

== vs ===

== coerces types before comparing. The rules for what coerces to what are genuinely hard to remember, which is why the standard advice is: always use ===. The one exception where == is useful is value == null, which is true for both null and undefined — handy when you want to catch both.

0 == false   // true
0 === false  // false
null == undefined  // true — useful
null === undefined // false

Arrow functions aren’t just shorter syntax

Regular functions get their own this at call time. Arrow functions don’t have a this — they inherit it from the enclosing scope. This distinction matters a lot once you’re passing callbacks around.

const obj = {
  name: 'Alice',
  greet() {
    return this.name; // 'Alice' — method call, this = obj
  },
  greetArrow: () => {
    return this.name; // undefined — arrow, this = outer scope
  }
};

Use arrows for callbacks and short utility functions. Use regular functions (or methods) when you need this to refer to the object.

Array methods that actually matter

map, filter, and reduce are the ones you’ll use constantly. They don’t mutate — they return new arrays.

const users = [
  { name: 'Alice', age: 30, active: true },
  { name: 'Bob',   age: 22, active: false },
  { name: 'Carol', age: 28, active: true },
];

users
  .filter(u => u.active)
  .map(u => u.name);
// ['Alice', 'Carol']

users.reduce((sum, u) => sum + u.age, 0);
// 80

find returns the first match or undefined. some / every return booleans. flat / flatMap handle nested arrays. forEach is for side effects only — it returns nothing.


🟡 Medior

Closures

A closure is a function that remembers the variables from the scope where it was defined, even after that scope is gone. This is how JavaScript does private state.

function makeCounter(start = 0) {
  let count = start;
  return {
    increment: () => ++count,
    decrement: () => --count,
    value:     () => count,
  };
}

const counter = makeCounter(10);
counter.increment(); // 11
counter.increment(); // 12
counter.value();     // 12

count is unreachable from outside. Nothing can set it directly. This is the module pattern, and it’s still useful even with ES modules.

The classic closure bug is in loops:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs 3, 3, 3 — all closures share the same `i`

Replace var with let and it logs 0, 1, 2 — let creates a new binding per iteration.

The prototype chain

JavaScript doesn’t have classical inheritance. It has objects linked to other objects. When you access a property, JavaScript walks up the chain of [[Prototype]] links until it finds it or hits null.

const animal = {
  breathe() { return 'breathing'; }
};

const dog = Object.create(animal);
dog.bark = function() { return 'woof'; };

dog.bark();    // 'woof' — own property
dog.breathe(); // 'breathing' — found on animal
Object.getPrototypeOf(dog) === animal; // true

ES6 class syntax is sugar over this exact mechanism. extends sets up the prototype chain, super() calls the parent constructor. Under the hood it’s the same thing.

The event loop

JavaScript is single-threaded. There’s one call stack, and code runs one frame at a time. The event loop picks the next task when the stack is empty.

There are two queues: the microtask queue (Promises, queueMicrotask) and the task queue (setTimeout, I/O, events). After each task completes, the engine drains the entire microtask queue before picking the next task.

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 1 → 4 → 3 → 2

setTimeout(..., 0) doesn’t mean “run immediately.” It means “put this in the task queue.” The Promise resolves faster because microtasks run before the next task.

Async/await and common mistakes

async/await is Promises with better syntax. Under the hood, an async function returns a Promise, and await pauses it until the awaited Promise settles.

async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

The common mistake is awaiting things in sequence when they could run in parallel:

// Slow — runs one after the other
const a = await fetchA();
const b = await fetchB();

// Fast — runs concurrently
const [a, b] = await Promise.all([fetchA(), fetchB()]);

Unhandled promise rejections will crash Node processes and log warnings in browsers. Always handle errors — either with try/catch inside the async function or .catch() on the returned Promise.

Modules

ES Modules are static — imports are resolved at parse time, not runtime. This is what enables tree shaking.

// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export default function multiply(a, b) { return a * b; }

// main.js
import multiply, { PI, add } from './math.js';

Default exports are imported without braces and can be named anything. Named exports must match (or be aliased with as). Mixing both in one file is fine, though many style guides prefer one or the other.


🔴 Senior

Memory leaks and the GC

JavaScript uses mark-and-sweep garbage collection. The GC marks everything reachable from roots (global, call stack, event listeners) and sweeps the rest. If something holds a reference, it won’t be collected — even if you think you’re done with it.

The four most common leak patterns:

Event listeners you don’t remove. Every addEventListener keeps the listener function and everything in its closure alive. If the element is removed from the DOM but you still hold a JS reference (or vice versa), nothing gets collected.

function setup(el, data) {
  el.addEventListener('click', () => processLargeArray(data)); // data is retained
}
// Fix: keep a reference to the handler and call removeEventListener when done

Detached DOM nodes. Remove a node from the document but keep a JS reference to it? The entire subtree stays in memory.

Closures over large objects. A timer or cached function that closes over a large dataset holds it in memory for as long as the timer or cache lives.

Growing caches with no eviction. A Map used as a cache that only ever grows. Use WeakMap when the cache should be keyed by object identity — it allows GC to collect entries when the key object is collected.

V8 hidden classes

V8 generates optimized machine code by assuming objects with the same “shape” (same properties, same order) are handled the same way. This is called hidden classes or shapes.

When you add properties to an object in different orders, or add them after construction, V8 creates a new hidden class and may deoptimize the code.

// Bad — different shapes
const a = {};
a.x = 1; a.y = 2;

const b = {};
b.y = 1; b.x = 2; // different shape from a

// Good — always initialize in the same order
function Point(x, y) {
  this.x = x;
  this.y = y;
}

This matters most when you’re creating millions of objects (e.g., game entities, time series data). For typical application code, don’t worry about it until you’ve profiled.

Generators

Generators are functions that can pause and resume. yield pauses and returns a value to the caller. Calling .next() resumes.

function* fibonacci() {
  let [a, b] = [0, 1];
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
fib.next().value; // 0
fib.next().value; // 1
fib.next().value; // 1
fib.next().value; // 2

The infinite sequence above works because it only computes values on demand. Generators are the foundation of async iteration (for await...of) and were the predecessor to async/await in libraries like co.

Things that will bite you

NaN !== NaN is true. Use Number.isNaN(value), not isNaN(value) — the global isNaN coerces its argument first, so isNaN('hello') is true.

0.1 + 0.2 is not 0.3. It’s 0.30000000000000004. This is IEEE 754 floating point — every language has this problem. For money, use integer cents or a library like decimal.js.

JSON.parse(JSON.stringify(obj)) is not a deep clone. It drops undefined, functions, Date objects (converted to strings), Map, Set, and circular references (throws). Use structuredClone() instead — it handles all of these properly.

Array.prototype.sort without a comparator sorts by string Unicode code point, not by number. [10, 9, 2].sort() gives [10, 2, 9]. Always pass a comparator: .sort((a, b) => a - b).

parseInt('09') works fine in modern engines, but always pass the radix to be safe: parseInt('09', 10).