Callbacks
The original asynchronous pattern — passing functions to be called later when work is done.
1. Introduction
JavaScript is single-threaded — it can only do one thing at a time. But it is also non-blocking: when it needs to wait for something (a network request, a timer, reading a file), it doesn't freeze. Instead, it hands the task off and continues running. When the task finishes, a callback function is called.
Callbacks are the original async mechanism in JavaScript. Event listeners (addEventListener), timers (setTimeout), and array methods (forEach, map) all use callbacks. Understanding them is essential — even though modern code uses Promises and async/await, those are built on top of callbacks.
2. Theory
2.1 What is a callback?
A callback is a function passed as an argument to another function, to be called when a specific event or condition occurs.
// A callback is just a function passed as an argument
function greet(name, callback) {
const message = `Hello, ${name}!`;
callback(message); // call the passed function with the result
}
greet('Alice', msg => console.log(msg)); // "Hello, Alice!"
greet('Bob', msg => alert(msg)); // alert popup
// You've used callbacks throughout the course:
[1, 2, 3].forEach(n => console.log(n)); // forEach callback
[1, 2, 3].map(n => n * 2); // map callback
btn.addEventListener('click', () => { }); // event callback
setTimeout(() => console.log('Later!'), 1000); // timer callback
2.2 Synchronous vs asynchronous callbacks
// SYNCHRONOUS callback — called immediately, inline
[1, 2, 3].map(n => n * 2); // callback runs before map returns
// ASYNCHRONOUS callback — called LATER, after some delay or event
console.log('Start');
setTimeout(() => {
console.log('Inside timeout'); // runs after 2 seconds
}, 2000);
console.log('End');
// Output order:
// "Start"
// "End"
// "Inside timeout" (after 2 seconds)
2.3 The event loop (simplified)
// JavaScript has a single call stack and an event queue
// When setTimeout fires, the callback goes into the event queue
// The event loop picks it up only when the call stack is empty
console.log('1 — synchronous');
setTimeout(() => console.log('3 — async (timeout)'), 0); // even 0ms = async!
console.log('2 — synchronous');
// Output: 1, 2, 3
// The 0ms timeout doesn't mean "immediate" — it means "after current code finishes"
2.4 Node-style callbacks — the error-first pattern
// Convention: first argument is error (null if none), rest is result
// Used in Node.js filesystem API, many older libraries
function readFile(filename, callback) {
// Simulate async file read
setTimeout(() => {
if (filename === 'missing.txt') {
callback(new Error('File not found'), null); // error case
} else {
callback(null, `Contents of ${filename}`); // success case
}
}, 100);
}
// Always check error first
readFile('data.txt', (err, data) => {
if (err) {
console.error('Error:', err.message);
return; // stop — don't use data
}
console.log('Data:', data); // "Contents of data.txt"
});
2.5 Simulating async with setTimeout
// Simulate a database lookup
function getUser(id, callback) {
const users = {
1: { name: 'Alice', role: 'admin' },
2: { name: 'Bob', role: 'user' }
};
setTimeout(() => {
const user = users[id];
if (user) {
callback(null, user);
} else {
callback(new Error(`User ${id} not found`), null);
}
}, 50); // simulate 50ms network delay
}
getUser(1, (err, user) => {
if (err) { console.error(err.message); return; }
console.log(`Found: ${user.name} (${user.role})`); // "Found: Alice (admin)"
});
getUser(99, (err, user) => {
if (err) { console.error(err.message); return; } // "User 99 not found"
console.log(user);
});
2.6 Callback hell — the problem with nested callbacks
// Scenario: login → get profile → get posts → get comments
// Each step depends on the previous — leads to deep nesting
login(username, password, (err, user) => {
if (err) { handleError(err); return; }
getProfile(user.id, (err, profile) => {
if (err) { handleError(err); return; }
getPosts(profile.id, (err, posts) => {
if (err) { handleError(err); return; }
getComments(posts[0].id, (err, comments) => {
if (err) { handleError(err); return; }
// Finally — 4 levels deep!
console.log(comments);
});
});
});
});
// This is "callback hell" / "pyramid of doom"
// It's hard to read, maintain, and handle errors
// Solution: Promises (Lesson 2) and async/await (Lesson 3)
2.7 Mitigating callback hell — named functions
// Flatten by using named functions instead of anonymous inline callbacks
function handleComments(err, comments) {
if (err) { handleError(err); return; }
console.log(comments);
}
function handlePosts(err, posts) {
if (err) { handleError(err); return; }
getComments(posts[0].id, handleComments);
}
function handleProfile(err, profile) {
if (err) { handleError(err); return; }
getPosts(profile.id, handlePosts);
}
function handleLogin(err, user) {
if (err) { handleError(err); return; }
getProfile(user.id, handleProfile);
}
login(username, password, handleLogin);
// Flat — but still verbose and hard to follow the flow
// This is why Promises were invented
2.8 When callbacks are still the right choice
// Callbacks are perfectly fine for:
// 1. Event listeners — single, recurring events
btn.addEventListener('click', handleClick);
// 2. Array methods — synchronous transforms
const doubled = [1,2,3].map(n => n * 2);
// 3. Simple one-off timers
setTimeout(() => updateClock(), 1000);
// 4. Library APIs that require them
// (many older libraries use callback-based APIs)
// Use Promises/async-await for:
// - Sequential async operations (fetch, then process, then save)
// - Multiple parallel async operations
// - Anything that needs clear error handling
3. Real World Example
// Image loader with loading/error callbacks
function loadImage(url, onLoad, onError) {
const img = new Image();
img.onload = () => onLoad(img);
img.onerror = () => onError(new Error(`Failed to load: ${url}`));
img.src = url;
}
function showImage(img) {
img.style.maxWidth = '100%';
document.body.appendChild(img);
}
function showError(err) {
const p = document.createElement('p');
p.style.color = 'red';
p.textContent = err.message;
document.body.appendChild(p);
}
loadImage('https://picsum.photos/400/300', showImage, showError);
loadImage('https://invalid.example.com/img.jpg', showImage, showError);
4. Code Example
<button id="run">Run Async Demo</button>
<ul id="log"></ul>
<script>
const log = document.querySelector('#log');
function addLog(msg, color = 'inherit') {
const li = document.createElement('li');
li.textContent = msg;
li.style.color = color;
log.appendChild(li);
}
// Simulate async data fetch
function fetchData(type, delay, callback) {
setTimeout(() => {
if (Math.random() < 0.1) { // 10% chance of failure
callback(new Error(`${type} request failed`), null);
} else {
callback(null, `${type} data received`);
}
}, delay);
}
document.querySelector('#run').addEventListener('click', () => {
log.innerHTML = '';
addLog('Starting async operations...');
// Fire multiple async operations — they run concurrently
fetchData('Users', 500, (err, data) => {
if (err) { addLog(err.message, 'red'); return; }
addLog(data, 'green');
});
fetchData('Products', 300, (err, data) => {
if (err) { addLog(err.message, 'red'); return; }
addLog(data, 'blue');
});
fetchData('Orders', 800, (err, data) => {
if (err) { addLog(err.message, 'red'); return; }
addLog(data, 'purple');
});
addLog('All requests sent — waiting...');
});
</script>
5. Code Breakdown
setTimeout simulates async
fetchData uses setTimeout to simulate a network delay. After the delay, it either calls callback(error, null) or callback(null, data) — following the error-first convention.
Concurrent execution
All three fetchData calls are fired almost simultaneously. They don't wait for each other — they all run concurrently. The one with the shortest delay (300ms for Products) resolves first. The "All requests sent" message logs before any of them complete.
Error-first in each callback
Each callback checks if (err) first and returns early on error. This prevents the success code from running when there's a failure — a critical pattern in callback-based code.
6. Common Mistakes
Mistake 1 — Forgetting to return after error check
// Bug — code continues after error!
fetchData((err, data) => {
if (err) console.error(err); // logs error but doesn't stop
processData(data); // crashes because data is null!
});
// Fix — always return after handling error
fetchData((err, data) => {
if (err) { console.error(err); return; } // return stops execution
processData(data); // only runs if no error
});
Mistake 2 — Calling callback synchronously when it should be async
// Inconsistent — sometimes sync, sometimes async. Breaks callers.
function getUser(id, callback) {
if (cache[id]) {
callback(null, cache[id]); // synchronous — runs before next line!
} else {
fetchFromDB(id, callback); // asynchronous
}
}
// Fix: always be async — wrap sync path in setTimeout or queueMicrotask
if (cache[id]) {
setTimeout(() => callback(null, cache[id]), 0);
}
Mistake 3 — Calling callback twice
// Bug — both success AND error called
function bad(callback) {
try {
const result = doThing();
callback(null, result);
} catch (e) {
callback(e); // also calls callback — now called twice!
}
callback(null, 'extra'); // called a third time!
}
// Fix: only call callback once, in each branch
7. Best Practices
- Always use error-first callbacks for async functions you write.
- Always check the error first and return early — never let code continue after an error.
- Never call a callback twice — once for success, once for error only.
- Use named functions to flatten nested callbacks and improve readability.
- For new code with sequential async operations, prefer Promises or async/await over nested callbacks.
8. Practice Exercise
- Write a
delay(ms, callback)function that calls callback after ms milliseconds. Use it to log three messages with 1-second intervals between each. - Write a
loadUser(id, callback)function that returns a user object after 200ms if id is valid (1–5), or calls callback with an error if not. Test both success and error cases. - Create a "waterfall" — chain three callbacks in sequence where each result feeds into the next: get user → get their orders → get the first order's details.
9. Assignment
Build a "Callback Sequencer" page.
- Create five simulated async operations (each uses setTimeout with different delays and random failure).
- A "Run Sequence" button executes them one by one (sequential — each waits for the previous).
- A "Run Parallel" button fires all five simultaneously.
- Display a live log showing: which task started, when it completed, its result or error.
- Show total elapsed time for both modes after all complete.
Deliverable: One HTML file.
10. Interview Questions
- What is a callback function?
A function passed as an argument to another function, to be called at a later point — either synchronously (like array methods) or asynchronously (like setTimeout or event listeners). - What is the event loop?
The mechanism that allows JavaScript to be non-blocking despite being single-threaded. Async callbacks are queued and only executed after the current call stack is empty. This is why setTimeout(fn, 0) still runs after synchronous code. - What is callback hell?
Deeply nested callbacks that form a "pyramid of doom" — hard to read, maintain, and error-handle. It occurs when sequential async operations each depend on the previous result. Solved by Promises and async/await. - What is the error-first callback convention?
A pattern where the callback's first argument is always an error (null if no error) and subsequent arguments are the result. Popularised by Node.js. Callers always check the error first before using the result.
11. Additional Resources
- MDN — Callbacks
- javascript.info — Introduction to callbacks
- MDN — Event loop — with visual diagram
- loupe — visualise the event loop (latentflip.com/loupe)