Skip to main content
โšก Calmops

JavaScript Core Concepts: Closures, Prototypes, Async, and the Event Loop

Introduction

JavaScript has a small set of core concepts that underpin everything else. Understanding closures, the prototype chain, and the event loop explains why JavaScript behaves the way it does โ€” and prevents the bugs that come from misunderstanding it.

Closures

A closure is a function that remembers the variables from its outer scope even after that scope has finished executing.

function makeCounter(start = 0) {
    let count = start;  // this variable is "closed over"

    return {
        increment() { count++; },
        decrement() { count--; },
        value()     { return count; },
    };
}

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

// count is not accessible from outside
console.log(counter.count);  // undefined

Why closures matter: They enable private state, factory functions, and callbacks that remember context.

// Practical: event handlers that remember data
function attachHandlers(items) {
    items.forEach((item, index) => {
        item.addEventListener('click', () => {
            console.log(`Clicked item ${index}: ${item.textContent}`);
            // index and item are closed over โ€” each handler remembers its own
        });
    });
}

// Practical: memoization
function memoize(fn) {
    const cache = new Map();
    return function(...args) {
        const key = JSON.stringify(args);
        if (cache.has(key)) return cache.get(key);
        const result = fn.apply(this, args);
        cache.set(key, result);
        return result;
    };
}

const expensiveCalc = memoize((n) => {
    console.log('Computing...');
    return n * n;
});

expensiveCalc(5);  // Computing... โ†’ 25
expensiveCalc(5);  // (from cache) โ†’ 25

The Prototype Chain

JavaScript uses prototypal inheritance. Every object has a [[Prototype]] link to another object. When you access a property, JavaScript walks up the chain until it finds it or reaches null.

const animal = {
    breathe() { return `${this.name} breathes`; }
};

const dog = Object.create(animal);
dog.name = 'Rex';
dog.bark = function() { return 'Woof!'; };

console.log(dog.bark());     // "Woof!" โ€” own property
console.log(dog.breathe());  // "Rex breathes" โ€” found on prototype
console.log(dog.toString()); // "[object Object]" โ€” found on Object.prototype

// The chain: dog โ†’ animal โ†’ Object.prototype โ†’ null
Object.getPrototypeOf(dog) === animal  // true

Classes Are Syntactic Sugar

class Animal {
    constructor(name) {
        this.name = name;
    }
    breathe() {
        return `${this.name} breathes`;
    }
}

class Dog extends Animal {
    bark() {
        return 'Woof!';
    }
}

const rex = new Dog('Rex');
rex.bark();     // "Woof!"
rex.breathe();  // "Rex breathes"

// Under the hood: same prototype chain
Object.getPrototypeOf(rex) === Dog.prototype  // true
Object.getPrototypeOf(Dog.prototype) === Animal.prototype  // true

The Event Loop

JavaScript is single-threaded but handles async operations through the event loop. Understanding this explains why setTimeout(fn, 0) doesn’t run immediately.

Call Stack          Web APIs           Task Queue        Microtask Queue
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€       โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€     โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€     โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
main()              setTimeout(...)    callback1         promise.then(...)
  โ†“                 fetch(...)
  โ†“                 addEventListener

The order:

  1. Execute synchronous code (call stack)
  2. Process all microtasks (Promise callbacks, queueMicrotask)
  3. Process one macrotask (setTimeout, setInterval, I/O)
  4. Repeat
console.log('1');  // synchronous

setTimeout(() => console.log('2'), 0);  // macrotask queue

Promise.resolve().then(() => console.log('3'));  // microtask queue

console.log('4');  // synchronous

// Output: 1, 4, 3, 2
// Explanation:
// 1 and 4 run synchronously
// 3 runs next (microtask, before macrotasks)
// 2 runs last (macrotask)
// Practical implication: Promise.then runs before setTimeout
async function example() {
    console.log('start');

    await Promise.resolve();  // yields to microtask queue
    console.log('after await');  // runs before any setTimeout

    setTimeout(() => console.log('timeout'), 0);
    console.log('end');
}

example();
// Output: start, after await, end, timeout

Async/Await and Promises

// Promise: represents a future value
const fetchUser = (id) => new Promise((resolve, reject) => {
    setTimeout(() => {
        if (id > 0) resolve({ id, name: 'Alice' });
        else reject(new Error('Invalid ID'));
    }, 100);
});

// async/await: syntactic sugar over Promises
async function getUser(id) {
    try {
        const user = await fetchUser(id);
        return user;
    } catch (err) {
        console.error('Failed:', err.message);
        return null;
    }
}

// Parallel execution
async function getDashboard(userId) {
    // BAD: sequential โ€” 300ms total
    const user    = await fetchUser(userId);
    const posts   = await fetchPosts(userId);
    const friends = await fetchFriends(userId);

    // GOOD: parallel โ€” ~100ms total
    const [user2, posts2, friends2] = await Promise.all([
        fetchUser(userId),
        fetchPosts(userId),
        fetchFriends(userId),
    ]);
}

Promise Combinators

// Promise.all: all must succeed, fails fast
const results = await Promise.all([fetch('/a'), fetch('/b'), fetch('/c')]);

// Promise.allSettled: wait for all, don't fail on errors
const results = await Promise.allSettled([fetch('/a'), fetch('/b')]);
results.forEach(r => {
    if (r.status === 'fulfilled') console.log(r.value);
    else console.error(r.reason);
});

// Promise.race: first to settle wins
const result = await Promise.race([
    fetch('/primary'),
    new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)),
]);

// Promise.any: first to succeed wins (ignores rejections)
const result = await Promise.any([fetch('/server1'), fetch('/server2')]);

this Binding

this is determined by how a function is called, not where it’s defined:

const obj = {
    name: 'Alice',
    greet() {
        console.log(`Hello, ${this.name}`);
    },
    greetArrow: () => {
        console.log(`Hello, ${this.name}`);  // this = outer scope (undefined in strict mode)
    },
};

obj.greet();           // "Hello, Alice" โ€” method call, this = obj
const fn = obj.greet;
fn();                  // "Hello, undefined" โ€” plain call, this = undefined (strict mode)

// Fix with bind
const boundGreet = obj.greet.bind(obj);
boundGreet();          // "Hello, Alice"

// Arrow functions inherit this from enclosing scope
class Timer {
    constructor() {
        this.seconds = 0;
    }
    start() {
        // Arrow function: this = Timer instance
        setInterval(() => {
            this.seconds++;  // works correctly
        }, 1000);
    }
}

Destructuring and Spread

// Object destructuring
const { name, age, address: { city } = {} } = user;
const { name: userName, ...rest } = user;  // rename + rest

// Array destructuring
const [first, second, ...remaining] = [1, 2, 3, 4, 5];
const [, , third] = [1, 2, 3];  // skip elements

// Function parameters
function display({ name, age = 0, role = 'user' } = {}) {
    console.log(name, age, role);
}

// Spread
const merged = { ...defaults, ...overrides };
const combined = [...arr1, ...arr2];
const copy = [...original];  // shallow copy

Modules (ESM)

// math.js โ€” named exports
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

// utils.js โ€” default export
export default function formatDate(date) {
    return date.toISOString().split('T')[0];
}

// app.js โ€” importing
import formatDate from './utils.js';           // default import
import { add, multiply } from './math.js';     // named imports
import { add as sum } from './math.js';        // rename
import * as math from './math.js';             // namespace import
import formatDate, { add } from './combined.js'; // both

// Dynamic import (lazy loading)
const module = await import('./heavy-module.js');

JSON: Serialization and Parsing

// Serialize to JSON string
const json = JSON.stringify({ name: 'Alice', age: 30 });
// '{"name":"Alice","age":30}'

// Pretty print
JSON.stringify(obj, null, 2);

// Custom serialization
JSON.stringify(obj, (key, value) => {
    if (key === 'password') return undefined;  // exclude field
    return value;
});

// Parse JSON string
const obj = JSON.parse('{"name":"Alice","age":30}');

// Safe parse
function safeParseJSON(str) {
    try {
        return { data: JSON.parse(str), error: null };
    } catch (err) {
        return { data: null, error: err.message };
    }
}

// Limitations: JSON doesn't support Date, undefined, functions, Map, Set
const date = new Date();
JSON.stringify(date)  // '"2026-03-30T10:00:00.000Z"' (string, not Date)
JSON.parse(JSON.stringify(date))  // string, not Date object

Semantic Versioning in package.json

{
  "dependencies": {
    "react": "^18.2.0",
    "lodash": "~4.17.21",
    "axios": "1.6.0"
  }
}
Notation Meaning Example
^18.2.0 Compatible with 18.x.x Allows 18.3.0, not 19.0.0
~4.17.21 Patch updates only Allows 4.17.22, not 4.18.0
1.6.0 Exact version Only 1.6.0
>=1.0.0 <2.0.0 Range Any 1.x.x
* Any version Dangerous โ€” avoid

Resources

Comments