Skip to main content

Advanced JavaScript - Event Loop, Promises, async/await That Actually Works

By · · 9 min read

Advanced JavaScript - When async/await Changed Everything

Introduction: The Day Callbacks Made Me Crazy

I remember debugging my first async JavaScript project. Callbacks nested so deep they looked like a pyramid. My code was unreadable. Then I spent a week digging through callback hell trying to fix a race condition.

I realized: async/await solved this. Once I understood the event loop and promises, async code became... simple? Natural? I could actually read my own code.

That's when JavaScript clicked for me. I went from writing fragile, confusing async code to code that actually made sense.

This guide covers the advanced JavaScript stuff that matters: how async really works, promises, closures, scope—the concepts that separate "code that barely works" from "code you can maintain."


1. Understanding the Event Loop

The JavaScript Runtime Architecture

┌─────────────────────────────────────────┐
│         JavaScript Engine               │
│  ┌──────┐  ┌──────┐  ┌──────────────┐  │
│  │Call  │  │ Heap │  │Web APIs      │  │
│  │Stack │  │      │  │(setTimeout,  │  │
│  │      │  │      │  │XMLHttpReq)   │  │
│  └──────┘  └──────┘  └──────────────┘  │
└─────────────────────────────────────────┘
         ↓
┌────────────────────────┐
│  Event Loop            │
│  (Manages queues)      │
└────────────────────────┘
         ↓
┌─────────────────────────────────────┐
│ Message Queue | Microtask Queue     │
└─────────────────────────────────────┘

How the Event Loop Works

// Timeline of execution with event loop

console.log('1. Start');

setTimeout(() => {
    console.log('2. setTimeout (macrotask)');
}, 0);

Promise.resolve()
    .then(() => {
        console.log('3. Promise (microtask)');
    });

console.log('4. End');

// Output:
// 1. Start
// 4. End
// 3. Promise (runs from microtask queue)
// 2. setTimeout (runs from macrotask queue)

Key Understanding:

Call Stack has work? → Execute it
Call Stack empty?
  → Process ALL microtasks (Promises, queueMicrotask)
  → Process ONE macrotask (setTimeout, setInterval)
  → Render (if needed)
  → Back to macrotask

Practical Event Loop Scenarios

// Scenario: Complex async flow
console.log('Start');

fetch('api.json')
    .then(res => res.json())
    .then(data => {
        console.log('Response:', data);
        return new Promise(resolve => {
            setTimeout(() => resolve('Delayed'), 100);
        });
    })
    .then(result => console.log('Result:', result));

Promise.resolve()
    .then(() => console.log('P1'))
    .then(() => console.log('P2'));

console.log('End');

/* Execution Order:
   1. Start (synchronous)
   2. End (synchronous)
   3. P1 (microtask)
   4. P2 (microtask)
   5. [fetch completes, then chained promises]
   6. Response: {...} (microtask)
   7. Result: 'Delayed' (after 100ms setTimeout)
*/

2. Closures - The Most Important Concept

What Are Closures?

A closure is a function that has access to variables from its outer (enclosing) scope, even after that outer function has returned.

Closure Mechanisms

// Example 1: Basic closure
function outer(x) {
    function inner(y) {
        return x + y;  // inner accesses outer's x
    }
    return inner;
}

const add5 = outer(5);
console.log(add5(3));  // 8
console.log(add5(10)); // 15

// The variable x persists in memory!
// This is a closure.

Practical Closure Applications

1. Data Privacy (Encapsulation)

function createCounter() {
    let count = 0;  // Private variable
    
    return {
        increment() { return ++count; },
        decrement() { return --count; },
        getCount() { return count; }
    };
}

const counter = createCounter();
counter.increment();  // 1
counter.increment();  // 2
counter.increment();  // 3
// count is private - cannot access directly
// counter.count = 100; // doesn't affect internal count

2. Function Factories

function createMultiplier(multiplier) {
    return function(number) {
        return number * multiplier;
    };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5));  // 10
console.log(triple(5));  // 15

3. Callbacks with State

function setupButtons() {
    const buttons = document.querySelectorAll('button');
    
    buttons.forEach((btn, index) => {
        btn.addEventListener('click', () => {
            // Each closure captures its own index
            console.log(`Button ${index} clicked`);
        });
    });
}

4. Module Pattern

const calculator = (() => {
    // Private variables
    const history = [];
    
    // Private function
    function saveToHistory(operation) {
        history.push(operation);
    }
    
    // Public API
    return {
        add(a, b) {
            const result = a + b;
            saveToHistory(`${a} + ${b} = ${result}`);
            return result;
        },
        subtract(a, b) {
            const result = a - b;
            saveToHistory(`${a} - ${b} = ${result}`);
            return result;
        },
        getHistory() {
            return [...history];  // Return copy
        }
    };
})();

calculator.add(5, 3);
calculator.subtract(10, 2);
console.log(calculator.getHistory());
// history is private - can only access via getHistory()

3. Promises and Async/Await

Promise States

Pending → Fulfilled (resolved with value)
       ↘ Rejected (rejected with error)
       (Once settled, state never changes)

Promise Chain Complexity

// Problem: Callback Hell (Pyramid of Doom)
getUserData(userId, function(err, user) {
    if (err) {
        handleError(err);
    } else {
        getOrdersData(user.id, function(err, orders) {
            if (err) {
                handleError(err);
            } else {
                getOrderDetails(orders[0].id, function(err, details) {
                    if (err) {
                        handleError(err);
                    } else {
                        processOrder(details);
                    }
                });
            }
        });
    }
});

// Solution: Promise chaining
getUserData(userId)
    .then(user => getOrdersData(user.id))
    .then(orders => getOrderDetails(orders[0].id))
    .then(details => processOrder(details))
    .catch(err => handleError(err));

// Better: Async/Await
async function processUserOrder(userId) {
    try {
        const user = await getUserData(userId);
        const orders = await getOrdersData(user.id);
        const details = await getOrderDetails(orders[0].id);
        await processOrder(details);
    } catch (err) {
        handleError(err);
    }
}

Parallel vs Sequential

// Sequential (waits after each)
async function sequential() {
    const user = await fetch('/user').then(r => r.json());
    const posts = await fetch('/user/posts').then(r => r.json());
    const comments = await fetch('/posts/comments').then(r => r.json());
    // Total time: T1 + T2 + T3
}

// Parallel (all requests simultaneously)
async function parallel() {
    const [user, posts, comments] = await Promise.all([
        fetch('/user').then(r => r.json()),
        fetch('/user/posts').then(r => r.json()),
        fetch('/posts/comments').then(r => r.json())
    ]);
    // Total time: max(T1, T2, T3)
    // Much faster!
}

Advanced Promise Patterns

// Promise.race - first to settle
Promise.race([
    fetch(url1),
    fetch(url2),
    new Promise((_, reject) => 
        setTimeout(() => reject('Timeout'), 5000)
    )
])
.then(response => console.log('First response:', response))
.catch(err => console.log('Error or timeout:', err));

// Promise.allSettled - all results regardless of success
Promise.allSettled([promise1, promise2, promise3])
    .then(results => {
        results.forEach((result, index) => {
            if (result.status === 'fulfilled') {
                console.log(`${index}: ${result.value}`);
            } else {
                console.log(`${index}: ${result.reason}`);
            }
        });
    });

// Custom promise wrapper for retries
function executeWithRetry(fn, retries = 3) {
    return fn().catch(err => 
        retries > 0 
            ? executeWithRetry(fn, retries - 1) 
            : Promise.reject(err)
    );
}

executeWithRetry(() => fetch(api), 3)
    .then(res => res.json());

4. Scope and Scope Chain

Lexical Scope (What JavaScript Uses)

// Scope chain: innermost → outer scopes → global

let global = 'global';

function outer() {
    let outerVar = 'outer';
    
    function middle() {
        let middleVar = 'middle';
        
        function inner() {
            let innerVar = 'inner';
            
            // Can access all:
            console.log(innerVar);   // inner
            console.log(middleVar);  // middle
            console.log(outerVar);   // outer
            console.log(global);     // global
        }
        
        inner();
    }
    
    middle();
}

outer();

Variable Hoisting and Temporal Dead Zone

// var hoisting - declaration hoisted, not initialization
console.log(x);  // undefined (not error!)
var x = 5;
console.log(x);  // 5

// let/const hoisting - ReferenceError if accessed before declaration
console.log(y);  // ReferenceError!
let y = 5;

// Temporal Dead Zone (TDZ): from scope start until declaration

function hoistingDemo() {
    // console.log(z);  // ReferenceError
    // Zone where z exists but not initialized
    
    let z = 5;
    console.log(z);  // 5
}

var vs let vs const

// var: function-scoped, hoisted, can be redeclared
var x = 1;
var x = 2;  // OK - redeclaration
if (true) {
    var x = 3;
}
console.log(x);  // 3 (leaks from block)

// let: block-scoped, hoisted (TDZ), cannot be redeclared
let y = 1;
// let y = 2;  // Error - redeclaration
{
    let y = 3;
    console.log(y);  // 3
}
console.log(y);  // 1 (block-scoped)

// const: block-scoped, must be initialized, immutable reference
const z = 1;
// z = 2;  // Error - immutable
z.prop = 'mutated';  // OK - can mutate object properties

5. Prototypes and Prototype Chain

Understanding Prototypes

// Every object has [[Prototype]] (internal property)
// Accessible via __proto__ or Object.getPrototypeOf()

function Animal(name) {
    this.name = name;
}

// Add method to prototype
Animal.prototype.speak = function() {
    return `${this.name} is speaking`;
};

const dog = new Animal('Rex');

// dog.__proto__ === Animal.prototype
// Dog object: { name: 'Rex', __proto__: Animal.prototype }
// When accessing dog.speak, looks in:
// 1. dog object itself
// 2. dog.__proto__ (Animal.prototype)
// 3. Animal.prototype.__proto__ (Object.prototype)
// 4. null (end of chain)

console.log(dog.speak());  // Rex is speaking

Prototype Chain for Property Lookup

dog instance
  ↓ (property not found)
Animal.prototype
  ↓ (property not found)
Object.prototype
  ↓ (property not found)
null (chain ends)

Prototypal Inheritance

// Constructor functions
function Animal(name) {
    this.name = name;
}

Animal.prototype.eat = function() {
    return `${this.name} is eating`;
};

function Dog(name, breed) {
    Animal.call(this, name);  // Call parent constructor
    this.breed = breed;
}

// Set prototype chain
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    return `${this.name} is barking`;
};

const myDog = new Dog('Rex', 'Labrador');
console.log(myDog.eat());   // Rex is eating (inherited)
console.log(myDog.bark());  // Rex is barking (own method)

ES6 Class Syntax (Syntactic Sugar Over Prototypes)

class Animal {
    constructor(name) {
        this.name = name;
    }
    
    eat() {
        return `${this.name} is eating`;
    }
    
    static info() {
        return 'Animals are living beings';
    }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }
    
    bark() {
        return `${this.name} is barking`;
    }
    
    // Override parent method
    eat() {
        return `${this.name} is eating dog food`;
    }
}

const dog = new Dog('Rex', 'Labrador');
console.log(dog.eat());      // Rex is eating dog food
console.log(dog.bark());     // Rex is barking
console.log(Animal.info());  // Animals are living beings

6. This Binding and Context

How "This" Works

// 1. Method call: this = object
const obj = {
    name: 'Object',
    greet() {
        return `Hello, ${this.name}`;
    }
};
console.log(obj.greet());  // Hello, Object

// 2. Function call (non-strict): this = undefined (global in non-strict)
function greet() {
    return `Hello, ${this.name}`;
}
greet();  // undefined (unless called on object)

// 3. Constructor call: this = new instance
function Person(name) {
    this.name = name;
}
const p = new Person('Raj');
console.log(p.name);  // Raj

// 4. Call/Apply/Bind: this = specified object
const person1 = { name: 'Alice' };
const person2 = { name: 'Bob' };

function introduce() {
    return `I am ${this.name}`;
}

console.log(introduce.call(person1));    // I am Alice
console.log(introduce.apply(person2));   // I am Bob
const boundFunc = introduce.bind(person1);
console.log(boundFunc());               // I am Alice

Arrow Functions "This" Binding

// Arrow functions DON'T have their own this
// They inherit from enclosing scope

const obj = {
    name: 'Object',
    
    // Regular function: this = obj
    regularMethod() {
        console.log(this.name);  // Object
    },
    
    // Arrow function: this = inherited (global/parent scope)
    arrowMethod: () => {
        console.log(this.name);  // undefined (global this)
    },
    
    // Arrow in nested function
    complexMethod() {
        setTimeout(() => {
            console.log(this.name);  // Object (inherited from method)
        }, 100);
    }
};

obj.regularMethod();   // Object
obj.arrowMethod();     // undefined
obj.complexMethod();   // Object

7. Performance Optimization

Debouncing and Throttling

// Debounce: wait until user stops (search input)
function debounce(fn, delay) {
    let timeoutId;
    return function(...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn(...args), delay);
    };
}

const search = debounce(async (term) => {
    const results = await fetch(`/search?q=${term}`);
    updateUI(results);
}, 300);

input.addEventListener('input', (e) => search(e.target.value));
// Only searches 300ms after user stops typing

// Throttle: execute at intervals (scroll events)
function throttle(fn, interval) {
    let lastRun = 0;
    return function(...args) {
        const now = Date.now();
        if (now - lastRun >= interval) {
            fn(...args);
            lastRun = now;
        }
    };
}

window.addEventListener('scroll', throttle(() => {
    updateScrollPosition();
}, 100));
// Executes at most every 100ms

Memory Management

// Problem: Memory leak from closures
function problematicSetup() {
    const largeData = new Array(1000000).fill('data');
    
    document.getElementById('btn').addEventListener('click', () => {
        console.log(largeData.length);  // Should use only length!
        // But entire largeData is kept in memory!
    });
}

// Solution: Extract only needed data
function goodSetup() {
    const largeData = new Array(1000000).fill('data');
    const dataLength = largeData.length;
    
    document.getElementById('btn').addEventListener('click', () => {
        console.log(dataLength);  // Only length kept in memory
    });
    
    // largeData can be garbage collected
}

8. Advanced Patterns

Observer Pattern (EventEmitter)

class EventEmitter {
    constructor() {
        this.events = {};
    }
    
    on(event, listener) {
        if (!this.events[event]) {
            this.events[event] = [];
        }
        this.events[event].push(listener);
    }
    
    emit(event, data) {
        if (this.events[event]) {
            this.events[event].forEach(listener => listener(data));
        }
    }
    
    off(event, listenerToRemove) {
        if (this.events[event]) {
            this.events[event] = this.events[event].filter(
                listener => listener !== listenerToRemove
            );
        }
    }
}

const emitter = new EventEmitter();

emitter.on('user:login', (user) => {
    console.log(`${user.name} logged in`);
});

emitter.emit('user:login', { name: 'Alice' });
// Alice logged in

Memoization Pattern

// Caching function results to avoid recalculation
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(...args);
        cache.set(key, result);
        return result;
    };
}

const memCalcFib = memoize((n) => {
    if (n <= 1) return n;
    return memCalcFib(n-1) + memCalcFib(n-2);
});

console.log(memCalcFib(40));  // Instant! (cached results)

Key Performance Metrics

Code Execution Time Benchmarks:
- Simple operation: < 1ms
- DOM query: 1-5ms
- Network request: 50-500ms
- Heavy computation: varies

JavaScript Performance Tips:
1. Minimize DOM manipulation
2. Use event delegation
3. Lazy load resources
4. Cache DOM queries
5. Use Promise.all for parallel operations
6. Avoid memory leaks (remove listeners)

Conclusion: Mastery Requirements

To master advanced JavaScript:

  1. Understand Event Loop - Crucial for async programming
  2. Master Closures - Used everywhere (callbacks, factory functions)
  3. Know Prototypes - Foundation of inheritance
  4. Async Programming - Promises and async/await fluency
  5. Memory Management - Prevent leaks in long-running code
  6. Performance - Optimize user experience

Practice with real projects and constantly review. JavaScript expertise takes time but is incredibly rewarding!