Learn JavaScript callbacks. Understand sync vs async callbacks, error-first patterns, callback hell, and why Promises were invented.
Why doesn’t JavaScript wait? When you set a timer, make a network request, or listen for a click, how does your code keep running instead of freezing until that operation completes?
Copy
Ask AI
console.log('Before timer')setTimeout(function() { console.log('Timer fired!')}, 1000)console.log('After timer')// Output:// Before timer// After timer// Timer fired! (1 second later)
The answer is callbacks: functions you pass to other functions, saying “call me back when you’re done.” As defined on MDN, a callback function is passed into another function as an argument and is then invoked inside the outer function. Callbacks power everything async in JavaScript. Every event handler, every timer, every network request. They all rely on them.
What you’ll learn in this guide:
What callbacks are and why JavaScript uses them
The difference between synchronous and asynchronous callbacks
How callbacks connect to higher-order functions
Common callback patterns (event handlers, timers, array methods)
The error-first callback pattern (Node.js convention)
Callback hell and the “pyramid of doom”
How to escape callback hell
Why Promises were invented to solve callback problems
Prerequisites: This guide assumes familiarity with the Event Loop. It’s the mechanism that makes async callbacks work! You should also understand higher-order functions, since callbacks are passed to higher-order functions.
A callback is a function passed as an argument to another function, that gets called later. The other function decides when (or if) to run it.
Copy
Ask AI
// greet is a callback functionfunction greet(name) { console.log(`Hello, ${name}!`)}// processUserInput accepts a callbackfunction processUserInput(callback) { const name = 'Alice' callback(name) // "calling back" the function we received}processUserInput(greet) // "Hello, Alice!"
The term “callback” comes from the idea of being called back. Think of it like getting a buzzer at a restaurant: “We’ll buzz you when your table is ready.”
Here’s the thing: A callback is just a regular function. Nothing magical about it. What makes it a “callback” is how it’s used: passed to another function to be executed later.
You don’t have to define callbacks as named functions. Anonymous functions (and arrow functions) work just as well:
Copy
Ask AI
// Named function as callbackfunction handleClick() { console.log('Clicked!')}button.addEventListener('click', handleClick)// Anonymous function as callbackbutton.addEventListener('click', function() { console.log('Clicked!')})// Arrow function as callbackbutton.addEventListener('click', () => { console.log('Clicked!')})
All three do the same thing. Named functions are easier to debug though, and you can reuse them.
Callbacks work like the buzzer you get at a busy restaurant:
You place an order — You call a function and pass it a callback
You get a buzzer — The function registers your callback
You go sit down — Your code continues running (non-blocking)
The buzzer goes off — The async operation completes
You pick up your food — Your callback is executed
Copy
Ask AI
┌─────────────────────────────────────────────────────────────────────────┐│ THE RESTAURANT BUZZER ANALOGY │├─────────────────────────────────────────────────────────────────────────┤│ ││ YOU (Your Code) RESTAURANT (JavaScript Runtime) ││ ││ ┌──────────────┐ ┌─────────────────────────────────┐ ││ │ │ │ KITCHEN │ ││ │ "I'd like │ ────────► │ (Web APIs) │ ││ │ a burger" │ ORDER │ │ ││ │ │ │ [setTimeout: 5 min] │ ││ └──────────────┘ │ [fetch: waiting...] │ ││ │ │ [click: listening...] │ ││ │ └─────────────────────────────────┘ ││ │ │ ││ │ You get a buzzer │ When ready... ││ │ and go sit down ▼ ││ │ ┌─────────────────────────────────┐ ││ │ │ PICKUP COUNTER │ ││ ▼ │ (Callback Queue) │ ││ ┌──────────────┐ │ │ ││ │ │ │ [Your callback waiting here] │ ││ │ 📱 BUZZ! │ ◄──────── │ │ ││ │ │ READY! └─────────────────────────────────┘ ││ │ Time to │ ││ │ eat! │ The Event Loop calls your callback ││ └──────────────┘ when the kitchen (Web API) is done ││ │└─────────────────────────────────────────────────────────────────────────┘
The key insight: you don’t wait at the counter. You give them a way to reach you (the callback), and you go do other things. That’s how JavaScript stays fast — it never sits around waiting. According to the 2023 State of JS survey, while most developers now prefer Promises and async/await for new code, callbacks remain foundational and are still used extensively in event handling and Node.js APIs.
Copy
Ask AI
// You place your order (start async operation)setTimeout(function eatBurger() { console.log('Eating my burger!') // This is the callback}, 5000)// You go sit down (your code continues)console.log('Sitting down, checking my phone...')console.log('Chatting with friends...')console.log('Reading the menu...')// Output:// Sitting down, checking my phone...// Chatting with friends...// Reading the menu...// Eating my burger! (5 seconds later)
A higher-order function is a function that accepts functions as arguments or returns them
A callback is the function being passed to a higher-order function
Copy
Ask AI
// forEach is a HIGHER-ORDER FUNCTION (it accepts a function)// The arrow function is the CALLBACK (it's being passed in)const numbers = [1, 2, 3]numbers.forEach((num) => { // ← This is the callback console.log(num * 2)})// 2, 4, 6
Every time you use map, filter, forEach, reduce, sort, or find, you’re passing callbacks to higher-order functions:
Copy
Ask AI
const users = [ { name: 'Alice', age: 25 }, { name: 'Bob', age: 17 }, { name: 'Charlie', age: 30 }]// filter accepts a callback that returns true/falseconst adults = users.filter(user => user.age >= 18)// map accepts a callback that transforms each elementconst names = users.map(user => user.name)// find accepts a callback that returns true when foundconst bob = users.find(user => user.name === 'Bob')// sort accepts a callback that compares two elementsconst byAge = users.sort((a, b) => a.age - b.age)
The connection: Understanding higher-order functions helps you understand callbacks. If you’re comfortable with map and filter, you already understand callbacks! The only difference with async callbacks is when they execute.
Asynchronous callbacks are executed later, after the current code finishes. They don’t block.
Copy
Ask AI
console.log('Before setTimeout')setTimeout(() => { console.log('Inside setTimeout')}, 0) // Even with 0ms delay!console.log('After setTimeout')// Output:// Before setTimeout// After setTimeout// Inside setTimeout (runs AFTER all sync code)
Even with a 0ms delay, the callback runs after the synchronous code. This is because async callbacks go through the event loop.Common asynchronous callbacks:
// Synchronous callback - try/catch WORKStry { [1, 2, 3].forEach(num => { if (num === 2) throw new Error('Found 2!') })} catch (error) { console.log('Caught:', error.message) // "Caught: Found 2!"}// Asynchronous callback - try/catch DOES NOT WORK!try { setTimeout(() => { throw new Error('Async error!') // This error escapes! }, 100)} catch (error) { // This will NEVER run console.log('Caught:', error.message)}// The error crashes your program!
Why? The try/catch runs immediately. By the time the async callback executes, the try/catch is long gone. The callback runs in a different “turn” of the event loop.
1: Script start4: Script end2: First timeout3: Second timeout
Even with a 0ms delay, the callbacks still run after all the synchronous code finishes.
Read more: Our Event Loop guide goes deep into tasks, microtasks, and rendering. If you want to understand whyPromise.then() runs before setTimeout(..., 0), check it out!
The most common use of callbacks in browser JavaScript:
Copy
Ask AI
// DOM eventsconst button = document.getElementById('myButton')button.addEventListener('click', function handleClick(event) { console.log('Button clicked!') console.log('Event type:', event.type) // "click" console.log('Target:', event.target) // the button element})// The callback receives an Event object with details about what happened
You can also use named functions for reusability:
Copy
Ask AI
function handleClick(event) { console.log('Clicked:', event.target.id)}function handleMouseOver(event) { event.target.style.backgroundColor = 'yellow'}button.addEventListener('click', handleClick)button.addEventListener('mouseover', handleMouseOver)// Later, you can remove them:button.removeEventListener('click', handleClick)
You can create your own functions that accept callbacks:
Copy
Ask AI
// A function that does something and then calls you backfunction fetchUserData(userId, callback) { // Simulate async operation setTimeout(function() { const user = { id: userId, name: 'Alice', email: '[email protected]' } callback(user) }, 1000)}// Using the functionfetchUserData(123, function(user) { console.log('Got user:', user.name)})console.log('Fetching user...')// Output:// Fetching user...// Got user: Alice (1 second later)
When Node.js came along, developers needed a standard way to handle errors in async callbacks. They landed on error-first callbacks (also called “Node-style callbacks” or “errbacks”).
// Error-first callback signaturefunction callback(error, result) { // error: null/undefined if success, Error object if failure // result: the data if success, usually undefined if failure}
The first parameter is always reserved for an error. If the operation succeeds, error is null or undefined. If it fails, error contains an Error object.
function divideAsync(a, b, callback) { // Simulate async operation setTimeout(function() { // Check for errors if (typeof a !== 'number' || typeof b !== 'number') { callback(new Error('Both arguments must be numbers')) return } if (b === 0) { callback(new Error('Cannot divide by zero')) return } // Success! Error is null, result is the value const result = a / b callback(null, result) }, 100)}// Using itdivideAsync(10, 2, function(error, result) { if (error) { console.error('Division failed:', error.message) return } console.log('Result:', result) // Result: 5})divideAsync(10, 0, function(error, result) { if (error) { console.error('Division failed:', error.message) // "Cannot divide by zero" return } console.log('Result:', result)})
// ❌ WRONG - code continues after error callback!function processData(data, callback) { if (!data) { callback(new Error('No data provided')) // Oops! Execution continues... } // This runs even when there's an error! const processed = transform(data) // Crash! data is undefined callback(null, processed)}// ✓ CORRECT - return after error callbackfunction processData(data, callback) { if (!data) { return callback(new Error('No data provided')) // Or: callback(new Error(...)); return; } // This only runs if data exists const processed = transform(data) callback(null, processed)}
Always return after calling an error callback! Otherwise, your code continues executing with invalid data.
When you have multiple async operations that depend on each other, callbacks nest inside callbacks. This creates the infamous “callback hell” or “pyramid of doom.”
This Promise chain is intentionally verbose to show how callbacks nest differently with Promises. For cleaner patterns and best practices, check out our Promises guide.
// The same flow with async/awaitasync function initDashboard(userId, password) { try { const user = await getUser(userId) const isValid = await verifyPassword(user, password) if (!isValid) throw new Error('Invalid password') const profile = await getProfile(user.id) const settings = await getSettings(user.id) renderDashboard(user, profile, settings) } catch (error) { handleError(error) }}
Promises and async/await are built on callbacks. They don’t replace callbacks. They provide a cleaner abstraction over them. Under the hood, Promise .then() handlers are still callbacks!
Mistake 2: Synchronous and Asynchronous Mixing (Zalgo)
A function should be consistently sync or async, never both. This inconsistency is nicknamed “releasing Zalgo,” a reference to an internet meme about unleashing chaos. And chaos is exactly what you get when code behaves unpredictably:
Copy
Ask AI
// ❌ WRONG - sometimes sync, sometimes async (Zalgo!)function getData(cache, callback) { if (cache.has('data')) { callback(null, cache.get('data')) // Sync! return } fetchFromServer(function(err, data) { callback(err, data) // Async! })}// This causes unpredictable behavior:let value = 'initial'getData(cache, function(err, data) { value = data})console.log(value) // "initial" or the data? Depends on cache!// ✓ CORRECT - always asyncfunction getData(cache, callback) { if (cache.has('data')) { // Use setTimeout to make it async (works in browsers and Node.js) setTimeout(function() { callback(null, cache.get('data')) }, 0) return } fetchFromServer(function(err, data) { callback(err, data) })}
JavaScript was created by Brendan Eich at Netscape in just 10 days. Its primary purpose was to make web pages interactive, responding to user clicks, form submissions, and other events.
JavaScript was designed to be single-threaded: one thing at a time. Why?
Simplicity — No race conditions, deadlocks, or complex synchronization
DOM Safety — Multiple threads modifying the DOM would cause chaos
Browser Reality — Early browsers couldn’t handle multi-threaded scripts
But single-threaded means a problem: you can’t block waiting for things.If JavaScript waited for a network request to complete, the entire page would freeze. Users couldn’t click, scroll, or do anything. That’s unacceptable for a UI language.
Register interest — “When this happens, call this function”
Continue immediately — Don’t block, keep the UI responsive
React later — When the event occurs, the callback runs
Copy
Ask AI
// This pattern was there from day oneelement.onclick = function() { alert('Clicked!')}// The page doesn't freeze waiting for a click// JavaScript registers the callback and moves on// When clicked, the callback runs
Question 5: Why can't you use try/catch with async callbacks?
Answer:The try/catch block executes synchronously. By the time an async callback runs, the try/catch is long gone. It’s on a different “turn” of the event loop.
Copy
Ask AI
try { setTimeout(() => { throw new Error('Async error!') // This escapes! }, 100)} catch (e) { // This NEVER catches the error console.log('Caught:', e)}// The error crashes the program because:// 1. try/catch runs immediately// 2. setTimeout registers callback and returns// 3. try/catch completes (nothing thrown yet!)// 4. 100ms later, callback runs and throws// 5. No try/catch exists at that point
This is why we use error-first callbacks or Promise .catch() for async error handling.
Question 6: What are three ways to avoid callback hell?
Answer:1. Named functions — Extract callbacks into named functions:
Copy
Ask AI
function handleUser(err, user) { if (err) return handleError(err) getProfile(user.id, handleProfile)}getUser(userId, handleUser)
2. Modularization — Split into separate modules/functions:
Copy
Ask AI
// auth.js exports authenticateUser()// profile.js exports loadProfile()// main.js composes them
3. Promises/async-await — Use modern async patterns:
Copy
Ask AI
const user = await getUser(userId)const profile = await getProfile(user.id)
Other approaches: control flow libraries (async.js), early returns, keeping nesting shallow.
A callback is a function passed as an argument to another function, which calls it later. As documented on MDN, callbacks are the fundamental mechanism for asynchronous programming in JavaScript. Every event handler, timer, and array method like forEach and map uses callbacks to execute code at the right time.
What is callback hell?
Callback hell (also called the “pyramid of doom”) is a pattern of deeply nested callbacks that makes code hard to read and maintain. It typically occurs when multiple asynchronous operations depend on each other. This problem was one of the primary motivations for adding Promises to the ECMAScript 2015 specification.
What is the error-first callback pattern?
The error-first pattern is a Node.js convention where callbacks receive an error as the first argument and the result as the second. If the error is null, the operation succeeded. This pattern became the de facto standard for Node.js APIs and was widely adopted before Promises. It ensures errors are always checked before processing results.
What is the difference between synchronous and asynchronous callbacks?
Synchronous callbacks execute immediately within the calling function — array methods like map and filter use synchronous callbacks. Asynchronous callbacks execute later, after some operation completes — setTimeout, fetch, and event listeners use asynchronous callbacks. The distinction matters because async callbacks require understanding the event loop and task scheduling.
Why were Promises invented to replace callbacks?
Promises solve three major callback problems: nested “pyramid of doom” code, inconsistent error handling, and inversion of control (trusting third-party code to call your callback correctly). According to the 2023 State of JS survey, async/await (built on Promises) is now the dominant async pattern, used by over 90% of JavaScript developers.