Skip to main content

🔄 Callbacks and the Event Loop

JavaScript can only think about one thing at a time — yet somehow a web page can download data, run an animation, and respond to your clicks all at once. The secret is a clever hand-off system: slow work is delegated, and a tireless coordinator called the event loop decides what runs next. Understand it once, and asynchronous JavaScript stops being magic.

Week 2 · Day 4 (Thursday: Asynchronous JavaScript) · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what "single-threaded" means and why JavaScript still feels responsive
  • Trace how the call stack, Web APIs, task queue, and microtask queue interact
  • Pass functions as callbacks and write the error-first callback pattern
  • Predict the output order of setTimeout vs. Promise callbacks
  • Recognize "callback hell" and describe why it motivated Promises
  • Use callbacks in real patterns like debouncing and progress tracking

Estimated Time: 65 minutes

Practice: Build a delayed calculator and a sequential data-fetching chain using callbacks.

In This Lesson

Why Asynchronous?

Imagine a chef who refuses to start the next order until the current dish is completely finished — including the twenty minutes it sits in the oven. The whole kitchen grinds to a halt while the chef stares at a closed oven door. That is synchronous, blocking code, and it would make any real application unusable.

A good chef instead starts the roast, sets a timer, and moves on to chop vegetables for the next order. When the timer dings, they come back to the roast. That is asynchronous code: kick off slow work, keep doing useful things, and handle the result when it's ready.

JavaScript leans on this pattern constantly, because so much of what a program waits on is slow and out of its control:

  • Fetching data from a server across the network (can take seconds)
  • Reading a large file from disk
  • Waiting for the user to click, type, or scroll
  • Timers that fire after a delay

Without asynchronous handling, your entire page would freeze — no scrolling, no clicking, nothing — every time it waited on one of these. Today we learn the machinery that prevents that freeze.

Single-Threaded, Never Frozen

JavaScript runs on a single thread: there is exactly one place where your code executes, one line at a time. It cannot literally do two things simultaneously. So how does it juggle a download and a click handler and an animation?

The trick is that the JavaScript engine does not work alone. It lives inside a host — a browser or Node.js — that provides extra machinery around it. The slow work is handed off to that machinery, which runs outside the single thread, and only the quick "here's your result, now what?" step comes back into JavaScript.

The JavaScript runtime: engine call stack plus host-provided Web APIs feeding queues watched by the event loop Call Stack one thing at a time main() doWork() Web APIs / host setTimeout fetch / network DOM events file I/O Callback Queue waiting callbacks hand off slow work event loop
The engine runs your code on one call stack; the host runs slow work; the event loop feeds finished callbacks back in when the stack is empty.

The call stack is a stack of the functions currently running. Call a function and it's pushed on top; when it returns, it's popped off. Because there's only one stack, JavaScript is doing exactly one thing at any instant. The stack must be empty before any waiting callback is allowed to run.

The Event Loop

The event loop is the coordinator — like an air-traffic controller who only clears a plane to land when the runway is free. Its job is astonishingly simple to state:

The rule: "Is the call stack empty? If so, take the next waiting callback and push it onto the stack. Repeat forever."

Here is that loop as a flow diagram. Nothing jumps the queue while your synchronous code is still running — the loop only acts once the stack clears.

graph TD A[Run synchronous code
on the call stack] --> B{Call stack empty?} B -->|No| A B -->|Yes| C{Any microtasks?} C -->|Yes| D[Run ALL microtasks
Promise callbacks] D --> C C -->|No| E{Any tasks?} E -->|Yes| F[Run ONE task
e.g. a setTimeout callback] F --> B E -->|No| G[Wait for new work] G --> B

Let's watch it work with the classic example. Read it top to bottom, then check the output:

console.log('First');

setTimeout(() => {
    console.log('Second');
}, 0);   // even 0ms does NOT mean "run now"

console.log('Third');

Output

First
Third
Second

Why does Second print last, even with a 0 delay? Because setTimeout doesn't run your callback — it hands it to the host, which parks the callback in the queue after the timer elapses. The event loop refuses to touch that queue until the current synchronous run (the console.log('Third')) has finished and the stack is empty. A 0ms timeout means "as soon as possible," not "immediately."

Callbacks: The Foundation

A callback is simply a function you pass into another function, so it can be called back later. It's like leaving a note for a coworker: "When you finish the dishes, call me on this number." You hand over the instructions; someone else decides when to run them.

The basic pattern

// greetUser accepts a function (the callback) as its second argument
function greetUser(name, callback) {
    console.log(`Hello, ${name}!`);
    callback();                 // run whatever was handed in
}

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

// Output:
// Hello, Alice!
// Greeting complete!

That example is synchronous — the callback runs immediately. Callbacks become powerful when the surrounding function is asynchronous and defers the call until some slow work is done.

An asynchronous callback

// Simulate reading a file: the "content" isn't ready until the timer fires
function readFile(filename, callback) {
    console.log(`Starting to read ${filename}...`);

    setTimeout(() => {                       // stand-in for slow disk I/O
        const content = `Content of ${filename}`;
        callback(content);                   // deliver the result later
    }, 1000);
}

readFile('data.txt', (data) => {
    console.log('File content:', data);
});

// Output (immediately):  Starting to read data.txt...
// Output (~1s later):     File content: Content of data.txt

The key insight: readFile returns before the content exists. The only way to use a value that isn't ready yet is to hand over a callback that will receive it later.

Error-First Callbacks

Asynchronous work can fail — the network drops, the file is missing, the input is bad. But you can't use a normal try/catch around code that finishes later, because the surrounding function has already returned. Node.js solved this with a convention: the callback's first argument is always the error (or null if all went well), and the result follows.

function fetchUserData(userId, callback) {
    setTimeout(() => {
        if (!userId) {
            // Convention: pass the error FIRST, no result
            callback(new Error('User ID is required'), null);
            return;
        }

        const user = { id: userId, name: 'John Doe', email: 'john@example.com' };
        callback(null, user);        // no error -> pass null, then the data
    }, 1000);
}

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

💡 Why "error first"?

Putting the error in a fixed, predictable slot means every caller checks it the same way — and it's hard to accidentally skip. If you forget the if (error) guard, you'll be working with undefined data and notice fast. This convention is everywhere in classic Node.js APIs.

Tasks vs. Microtasks

There isn't just one waiting line. The event loop watches two queues with different priorities, and knowing which is which lets you predict output order exactly.

QueueWhat lands herePriority
Microtask queuePromise .then/.catch/.finally, queueMicrotask, await continuationsHigher — drained completely after each task
Task queue (macrotask)setTimeout, setInterval, DOM events, network callbacksLower — only one runs per loop turn

The rule the loop follows: after the synchronous code finishes, drain every microtask, then run one task, then drain microtasks again, and so on. Microtasks always cut the line ahead of the next timeout.

console.log('Script start');

setTimeout(() => {
    console.log('setTimeout');          // a TASK  (lower priority)
}, 0);

Promise.resolve().then(() => {
    console.log('Promise');             // a MICROTASK (higher priority)
});

console.log('Script end');

Output

Script start
Script end
Promise      <- microtask drains before any task
setTimeout   <- the task runs last

Both async callbacks wait for the synchronous lines to finish. Then the microtask (Promise) runs before the task (setTimeout), even though the timeout was registered first. Here is the exact sequence the loop takes:

sequenceDiagram participant S as Sync code participant M as Microtask queue participant T as Task queue S->>S: log 'Script start' S->>T: register setTimeout callback S->>M: register Promise .then S->>S: log 'Script end' Note over S: call stack now empty M->>M: log 'Promise' Note over M: microtasks drained T->>T: log 'setTimeout'

Callback Hell

Callbacks work — but they don't compose gracefully. The moment you need several async steps in sequence, where each depends on the last, the callbacks nest inside one another and drift ever rightward into a "pyramid of doom."

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);

                // Four levels deep just to reach the data we wanted
                console.log(shipping);
            });
        });
    });
});

⚠️ Why this hurts

The error check is repeated at every level, the logic reads inside-out instead of top-to-bottom, and adding a step means re-indenting everything below it. Refactoring is risky and testing is awkward. This pain is exactly what Promises — and later async/await — were designed to cure. You'll meet Promises in the very next lesson.

Real-World Patterns

Callbacks are far from obsolete. Event listeners, timers, and many browser APIs are callback-based, and two patterns show up constantly.

Debouncing — wait until the user stops

When validating an email field or firing a search as the user types, you don't want to run on every keystroke. Debouncing delays the work until input has been quiet for a moment, using a callback and a resettable timer.

function debounce(fn, delay) {
    let timeoutId;
    return function (...args) {
        clearTimeout(timeoutId);             // cancel the previous pending run
        timeoutId = setTimeout(() => {
            fn.apply(this, args);            // only fires after `delay` ms of quiet
        }, delay);
    };
}

// Validate the email only 300ms after the user stops typing
const validateEmail = debounce((email) => {
    console.log('Validating email:', email);
}, 300);

document.getElementById('email')
    .addEventListener('input', (e) => validateEmail(e.target.value));

Progress tracking

Some operations report progress along the way. Two callbacks — one for updates, one for completion — model this cleanly.

function uploadFile(file, onProgress, onComplete) {
    let progress = 0;
    const interval = setInterval(() => {
        progress += 10;
        onProgress(progress);                // called repeatedly: 10, 20, 30...
        if (progress >= 100) {
            clearInterval(interval);
            onComplete(null, { success: true, filename: file });
        }
    }, 500);
}

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

Practice & Quiz

🏋️ Exercise 1: Delayed calculator

Goal: Write delayedCalculator(a, b, operation, callback) that waits ~500ms, then calls the error-first callback with the result. Support 'add', 'subtract', 'multiply', and 'divide', and reject division by zero.

function delayedCalculator(a, b, operation, callback) {
    // TODO: after a short delay, callback(error, result)
}

delayedCalculator(10, 5, 'add', (err, result) => {
    console.log(result);   // 15
});
delayedCalculator(10, 0, 'divide', (err, result) => {
    console.log(err.message);   // "Cannot divide by zero"
});
💡 Hint

Wrap the work in setTimeout. Inside it, switch on operation. For divide, check b === 0 first and call callback(new Error('Cannot divide by zero')). On success call callback(null, result).

✅ Solution
function delayedCalculator(a, b, operation, callback) {
    setTimeout(() => {
        switch (operation) {
            case 'add':      return callback(null, a + b);
            case 'subtract': return callback(null, a - b);
            case 'multiply': return callback(null, a * b);
            case 'divide':
                if (b === 0) return callback(new Error('Cannot divide by zero'));
                return callback(null, a / b);
            default:
                return callback(new Error(`Unknown operation: ${operation}`));
        }
    }, 500);
}

🏋️ Exercise 2: Sequential data fetcher

Goal: Using callback-based helpers, fetch a user, then their posts, then the comments on the first post, and pass a combined object to a final callback.

💡 Hint

Call the next step inside the previous step's callback, threading the data through. Check for errors at every level and short-circuit with return callback(err).

✅ Solution
function fetchUserDataChain(userId, done) {
    getUser(userId, (err, user) => {
        if (err) return done(err);
        getPosts(user.id, (err, posts) => {
            if (err) return done(err);
            getComments(posts[0].id, (err, comments) => {
                if (err) return done(err);
                done(null, { user, posts, comments });
            });
        });
    });
}
// Notice the rightward drift — this is the callback hell
// that Promises will flatten in the next lesson.

🎯 Quick Quiz

Question 1: With an empty call stack, which runs first?

Question 2: What does setTimeout(fn, 0) actually guarantee?

Question 3: In the error-first convention, what is the callback's first argument?

Best Practices & Pitfalls

✅ Do

  • Always check the error argument first in error-first callbacks
  • Keep async work off the main thread — never busy-wait in a loop
  • Name your callbacks (onComplete, handleResult) so nesting stays readable
  • Reach for Promises or async/await once you need more than one sequential step

❌ Don't

  • Assume setTimeout(fn, 0) runs "right now" — it never interrupts running code
  • Nest callbacks four levels deep when a Promise chain would flatten it
  • Forget to clear timers/intervals (clearTimeout, clearInterval) — they leak
  • Call the callback more than once for a single operation

⚠️ Blocking the loop freezes the page

// Never do this — a long synchronous loop hogs the single thread
const end = Date.now() + 5000;
while (Date.now() < end) { /* spin */ }   // page is frozen for 5s
// Clicks, scrolls, and animations all stall until this returns.

The event loop can't run any callback while the stack is busy. Keep synchronous work short; delegate anything slow.

Summary

🎉 Key Takeaways

  • JavaScript is single-threaded; the host (browser/Node) runs slow work off to the side
  • The call stack must be empty before any callback runs
  • The event loop feeds waiting callbacks back in when the stack clears
  • Microtasks (Promises) are drained fully before the next task (setTimeout) runs
  • A callback is a function passed in to be called later; the error-first convention rules Node.js
  • Deeply nested callbacks (callback hell) motivated Promises and async/await

📚 Additional Resources

🚀 What's Next?

You've seen how callbacks work — and where they buckle under nesting. The next lesson introduces the object that replaced them: a Promise represents a value that will exist later, with clean .then/.catch chaining and the Promise.all family of combinators. Onward to Promises.

🎉 Great work!

You now understand the engine underneath every async feature you'll ever build. The event loop will make sense of everything that follows.