Home › Technical › JavaScript

JavaScript core

The fundamentals you'll be asked to explain. None of this is novel for you — but make sure you can articulate it in spoken form, fluently, in 30–60 seconds. The bar isn't knowing; it's explaining well.

01Variables & scope

Difference between var, let, and const?

var is function-scoped, hoisted with initialization to undefined, and can be redeclared. Mostly legacy.

let is block-scoped, hoisted but in a "temporal dead zone" — accessing it before declaration throws a ReferenceError. Can be reassigned.

const is block-scoped like let, but the binding can't be reassigned. The value itself is still mutable if it's an object or array.

const arr = [1, 2];
arr.push(3);     // OK — mutating the array
arr = [];        // TypeError — reassigning the binding

I default to const, reach for let when I need reassignment, never use var in modern code.

What is hoisting?

JavaScript engines move declarations to the top of their scope at parse time. Function declarations are fully hoisted — you can call them before they're written in source order. var is hoisted and initialized to undefined. let and const are hoisted but live in the temporal dead zone until their declaration line is reached.

console.log(x); // undefined  (var hoisted, initialized to undefined)
var x = 5;

console.log(y); // ReferenceError  (let in TDZ)
let y = 5;
What's a closure?

A closure is a function bundled together with the lexical environment it was created in. It "remembers" the variables of its enclosing scope even after that scope has finished executing.

function makeCounter() {
  let count = 0;
  return () => ++count;
}
const inc = makeCounter();
inc(); // 1
inc(); // 2  — `count` survives via the closure

I use closures all the time — every event handler that references state, every memoization helper, every module pattern. It's also the source of common React bugs: stale closures in useEffect, where the function captures old state.

02this binding

How does this work in JavaScript?

this is determined at call time, not at definition time, except for arrow functions. Four rules in order of precedence:

  1. new bindingnew Foo() sets this to the new instance
  2. explicit bindingfn.call(obj), fn.apply(obj), fn.bind(obj)
  3. implicit bindingobj.method() sets this to obj
  4. default binding — undefined in strict mode, the global object otherwise

Arrow functions don't have their own this — they capture it lexically from the surrounding scope. That's why arrow functions are usually what you want for callbacks inside class methods.

class Timer {
  constructor() {
    this.seconds = 0;
    setInterval(() => this.seconds++, 1000); // arrow keeps `this`
  }
}

03Async

Explain the event loop.

JavaScript runs on a single thread. The event loop is the mechanism that processes async work. It has:

  • The call stack — currently executing code
  • The microtask queue — promise callbacks, queueMicrotask, MutationObserver
  • The macrotask queue — setTimeout, setInterval, I/O, UI events

The loop runs the call stack to completion, then drains all microtasks, then takes one macrotask, then drains microtasks again, and so on. That's why a chain of .then() calls runs before any setTimeout(0).

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Logs: 1, 4, 3, 2
Promises vs async/await — what's the difference?

They're the same machinery with different syntax. async/await is sugar over promises. async makes a function return a promise; await pauses the function until a promise resolves and unwraps its value.

// Promise style
fetch('/api').then(r => r.json()).then(data => console.log(data));

// async/await
async function load() {
  const r = await fetch('/api');
  const data = await r.json();
  console.log(data);
}

Common gotchas:

  • await in a loop runs sequentially. If you want parallel, use Promise.all.
  • Errors in async functions need try/catch or a .catch() on the returned promise — uncaught rejections will bite.
  • Forgetting await returns a promise, not the value. TypeScript catches this; vanilla JS doesn't.
What's a microtask vs a macrotask?

Microtasks are higher priority. After every task on the call stack finishes, the engine drains all microtasks before doing anything else. Macrotasks are queued one at a time between microtask drains.

Microtasks: promise .then/.catch/.finally, queueMicrotask, MutationObserver callbacks.

Macrotasks: setTimeout, setInterval, I/O, UI rendering, postMessage.

Why it matters: a long chain of microtasks can starve rendering and event handling. If you're seeing UI freeze during heavy promise chains, that's why.

04Objects, equality, copying

Shallow copy vs deep copy?

Shallow copies one level — top-level keys are copied, but nested objects are still shared by reference.

const a = { x: 1, nested: { y: 2 } };
const b = { ...a };
b.nested.y = 99;
console.log(a.nested.y); // 99 — shared reference!

Deep copy recursively copies everything. Options:

  • structuredClone(obj) — modern, native, handles cycles, doesn't copy functions
  • JSON.parse(JSON.stringify(obj)) — works for plain data, drops Dates/Maps/undefined/functions
  • lodash cloneDeep — battle-tested for edge cases
== vs ===?

=== is strict equality — no type coercion. == coerces types and has surprising behavior (0 == '' is true, null == undefined is true). Always use === in modern code.

What's a Symbol? When have you used one?

Symbols are unique, immutable primitives. Two symbols with the same description are still different. Use cases: object keys that won't collide with anyone else's keys, well-known protocols like Symbol.iterator to make objects iterable.

Honest answer: in most application code I rarely create Symbols directly. I interact with them through library APIs (iterables, Symbol.iterator, etc).

05Functions & higher-order patterns

What's currying?

Transforming a function that takes multiple args into a chain of functions that each take one. add(a, b, c) becomes add(a)(b)(c). Useful for partial application — bake in the first few args, get back a new function ready to take the rest.

const multiply = a => b => a * b;
const double = multiply(2);
double(5); // 10
Difference between map, filter, reduce?

All three iterate an array, all three return a new value, none mutate the original.

  • map: returns a new array with each element transformed
  • filter: returns a new array with elements that pass a predicate
  • reduce: collapses the array to a single value via an accumulator
[1, 2, 3].map(n => n * 2);          // [2, 4, 6]
[1, 2, 3].filter(n => n > 1);       // [2, 3]
[1, 2, 3].reduce((sum, n) => sum + n, 0); // 6
Debounce vs throttle?

Both limit how often a function runs in response to rapid events.

  • Debounce: only run after the events have stopped for X ms. Use case: search-as-you-type, where you want one request after the user stops typing.
  • Throttle: run at most once every X ms. Use case: scroll handlers, resize handlers, where you want regular updates but not on every event.

06Modules & imports

ES modules vs CommonJS?

CommonJS (require, module.exports) is synchronous, dynamic, Node's original module system. You can require conditionally, the resolution happens at runtime.

ES modules (import, export) are static — imports are resolved at parse time. This enables tree-shaking (removing unused exports at build time) and is the standard everywhere now.

Modern frontend code is ESM. Modern Node supports both, but ESM is the future. Tree-shaking is the practical reason you care.

What's tree-shaking?

A bundler optimization that removes unused exports from your final bundle by analyzing the static import graph. Requires ES modules — CommonJS's dynamic nature means the bundler can't safely know what's used.

Things that defeat tree-shaking: side-effectful imports, default-exporting an object of methods (whole object gets bundled), libraries that don't mark themselves as side-effect-free in package.json.

07Common "trick" questions

What does this print?
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

Prints 3 3 3. Because var is function-scoped, all three timeouts close over the same i, which is 3 by the time they run. Change to let and you get 0 1 2 — let creates a new binding per iteration.

What does this print?
console.log([] + []);     // ?
console.log([] + {});     // ?
console.log({} + []);     // ?

"" , "[object Object]", depends on context. JavaScript's coercion is famously weird. The point of these questions in interviews is usually not to memorize answers but to demonstrate you understand why — the + operator coerces, arrays stringify by joining with commas, objects stringify to "[object Object]".

If asked, the right answer is "I'd run it to find out — these coercions are exactly why I use === and avoid + with non-numbers."

What's the output?
async function f() {
  console.log('1');
  await null;
  console.log('2');
}
f();
console.log('3');

Logs 1, 3, 2. The await suspends the async function and queues the rest as a microtask, even though we're awaiting nothing. The synchronous console.log('3') runs first, then the microtask fires.