Asynchronous JavaScript

Callbacks and the Event Loop

Understanding Asynchronous JavaScript

Today we'll dive into one of JavaScript's most powerful features: asynchronous programming. Think of it like a restaurant kitchen - while one dish is in the oven, the chef doesn't stand there waiting; they start preparing the next order. This is exactly how JavaScript handles asynchronous operations.

Why Asynchronous JavaScript?

JavaScript is single-threaded, meaning it can only do one thing at a time. But what happens when we need to:

Without asynchronous programming, our entire application would freeze while waiting. Imagine a website that stops responding every time it loads an image!

The Event Loop: JavaScript's Traffic Controller

The event loop is like an air traffic controller, managing which code gets to run and when. Let's visualize how it works:

graph TD A[Call Stack] -->|Executes Functions| B[Web APIs] B -->|Tasks Complete| C[Callback Queue] C -->|Event Loop Checks| D{Is Stack Empty?} D -->|Yes| E[Move Callback to Stack] D -->|No| F[Wait] E --> A F --> D

Here's a simple example to understand the event loop:

console.log('First');

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

console.log('Third');

// Output:
// First
// Third  
// Second

Even with a 0ms delay, "Second" prints last! This is because setTimeout is handled by the browser's Web API, and its callback goes to the queue, waiting for the call stack to be empty.

Callbacks: The Foundation of Async

A callback is simply a function passed to another function, to be executed later. It's like leaving instructions for someone: "When you finish washing the dishes, please call me."

Basic Callback Pattern

function greetUser(name, callback) {
    console.log(`Hello, ${name}!`);
    callback();
}

greetUser('Alice', function() {
    console.log('Greeting complete!');
});

Real-World Example: Reading Files

// Simulating file reading (in Node.js style)
function readFile(filename, callback) {
    console.log(`Starting to read ${filename}...`);
    
    // Simulate async operation
    setTimeout(() => {
        const content = `Content of ${filename}`;
        callback(null, content);
    }, 1000);
}

// Usage
readFile('data.txt', (error, data) => {
    if (error) {
        console.error('Error reading file:', error);
        return;
    }
    console.log('File content:', data);
});

Error-First Callback Pattern

In Node.js and many JavaScript APIs, callbacks follow the "error-first" pattern:

function fetchUserData(userId, callback) {
    setTimeout(() => {
        if (!userId) {
            callback(new Error('User ID is required'), null);
            return;
        }
        
        const user = {
            id: userId,
            name: 'John Doe',
            email: 'john@example.com'
        };
        
        callback(null, user);
    }, 1000);
}

// Usage
fetchUserData(123, (error, user) => {
    if (error) {
        console.error('Failed to fetch user:', error.message);
        return;
    }
    console.log('User data:', user);
});

The Callback Hell Problem

When you need to perform multiple async operations in sequence, callbacks can become nested and hard to read:

getUserData(userId, (err, user) => {
    if (err) return handleError(err);
    
    getOrders(user.id, (err, orders) => {
        if (err) return handleError(err);
        
        getOrderDetails(orders[0].id, (err, details) => {
            if (err) return handleError(err);
            
            getShippingInfo(details.shippingId, (err, shipping) => {
                if (err) return handleError(err);
                
                // Finally, we have all the data!
                console.log(shipping);
            });
        });
    });
});

This "pyramid of doom" is why we needed better solutions...

Practical Examples

Example 1: Simulating API Calls

// Simulating an API call
function fetchWeather(city, callback) {
    console.log(`Fetching weather for ${city}...`);
    
    // Simulate network delay
    setTimeout(() => {
        const weatherData = {
            city: city,
            temperature: Math.floor(Math.random() * 30) + 10,
            condition: ['Sunny', 'Cloudy', 'Rainy'][Math.floor(Math.random() * 3)]
        };
        
        callback(null, weatherData);
    }, 1500);
}

// Usage
fetchWeather('New York', (error, data) => {
    if (error) {
        console.error('Weather fetch failed:', error);
        return;
    }
    
    console.log(`Weather in ${data.city}:`);
    console.log(`Temperature: ${data.temperature}°C`);
    console.log(`Condition: ${data.condition}`);
});

Example 2: Sequential Operations

// Multiple async operations in sequence
function step1(callback) {
    setTimeout(() => {
        console.log('Step 1 complete');
        callback(null, 'data from step 1');
    }, 1000);
}

function step2(data, callback) {
    setTimeout(() => {
        console.log('Step 2 complete, received:', data);
        callback(null, 'data from step 2');
    }, 1000);
}

function step3(data, callback) {
    setTimeout(() => {
        console.log('Step 3 complete, received:', data);
        callback(null, 'final result');
    }, 1000);
}

// Execute in sequence
step1((err, result1) => {
    if (err) return console.error(err);
    
    step2(result1, (err, result2) => {
        if (err) return console.error(err);
        
        step3(result2, (err, finalResult) => {
            if (err) return console.error(err);
            
            console.log('All steps complete:', finalResult);
        });
    });
});

Event Loop in Action

Let's see how the event loop handles different async operations:

console.log('Script start');

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

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

console.log('Script end');

// Output:
// Script start
// Script end
// Promise
// setTimeout

Notice that Promises have priority over setTimeout in the event loop!

graph TD A[Execution Start] --> B[Log 'Script start'] B --> C[setTimeout registered] C --> D[Promise registered] D --> E[Log 'Script end'] E --> F[Call Stack Empty] F --> G[Microtask Queue: Promise] G --> H[Log 'Promise'] H --> I[Task Queue: setTimeout] I --> J[Log 'setTimeout']

Real-World Applications

1. Form Validation with Debouncing

function debounce(func, delay) {
    let timeoutId;
    
    return function(...args) {
        clearTimeout(timeoutId);
        
        timeoutId = setTimeout(() => {
            func.apply(this, args);
        }, delay);
    };
}

// Usage: Validate email as user types
const validateEmail = debounce((email) => {
    console.log('Validating email:', email);
    // Perform validation logic here
}, 300);

// Attach to input event
document.getElementById('email').addEventListener('input', (e) => {
    validateEmail(e.target.value);
});

2. Progress Tracking

function uploadFile(file, progressCallback, completeCallback) {
    let progress = 0;
    
    const interval = setInterval(() => {
        progress += 10;
        progressCallback(progress);
        
        if (progress >= 100) {
            clearInterval(interval);
            completeCallback(null, { success: true, filename: file });
        }
    }, 500);
}

// Usage
uploadFile('document.pdf', 
    (progress) => {
        console.log(`Upload progress: ${progress}%`);
    },
    (error, result) => {
        if (error) {
            console.error('Upload failed:', error);
            return;
        }
        console.log('Upload complete:', result);
    }
);

Practice Exercises

Exercise 1: Create a Delayed Calculator

Create a calculator that performs operations with a delay:

function delayedCalculator(a, b, operation, callback) {
    // Your code here
    // Should support 'add', 'subtract', 'multiply', 'divide'
    // Include error handling for division by zero
}

// Test your function
delayedCalculator(10, 5, 'add', (error, result) => {
    console.log(result); // Should log 15 after delay
});

Exercise 2: Sequential Data Fetcher

Create a function that fetches user data, then their posts, then comments on the first post:

function fetchUserDataChain(userId, callback) {
    // Your code here
    // 1. Fetch user data
    // 2. Fetch user's posts
    // 3. Fetch comments on first post
    // Return all data combined
}

Key Takeaways

Looking Ahead

In our next session, we'll explore how Promises and async/await solve the callback hell problem and make asynchronous code more readable and maintainable.

Preview of What's Coming:

// The future of async JavaScript
async function fetchDataModern() {
    try {
        const user = await fetchUser();
        const posts = await fetchPosts(user.id);
        const comments = await fetchComments(posts[0].id);
        return { user, posts, comments };
    } catch (error) {
        console.error('Error:', error);
    }
}

Additional Resources