Callbacks confused me for months when I started JavaScript. Every tutorial said “just pass a function as an argument,” but nobody explained why or when you’d actually use them. Here’s the explanation I wish I’d found.
The Problem
JavaScript is built on asynchronous operations—API calls, file reads, timers—but when you’re coming from synchronous languages like C# or Java, the callback pattern feels backwards.
You write code like this:
getData(function(result) { console.log(result);});And you think: “Why not just let result = getData() and be done with it?”
Because JavaScript doesn’t wait. If getData() takes 2 seconds, your code keeps running. Callbacks are how you say “run this code when the data finally arrives.”
What Are Callbacks, Really?
A callback is just a function you pass to another function. That’s it.
The key insight: JavaScript treats functions as values. You can:
- Store them in variables
- Pass them as arguments
- Return them from functions
So when you pass a function as an argument, the receiving function can call it back later (hence “callback”).
The Simplest Example
function sayHello(callback) { callback("Hello");}
sayHello(function(greeting) { console.log(greeting + ", World!");});
// Output: Hello, World!Here’s what’s happening:
sayHellotakes one parameter:callback(which is a function)- Inside
sayHello, we callcallback("Hello"), passing"Hello"as an argument - The anonymous function we passed receives
"Hello"asgreetingand logs it
That anonymous function is the callback.
Why Would You Ever Do This?
Callbacks shine when you can’t return a value immediately. Here’s a more realistic example:
function fetchUserData(userId, callback) { // Imagine this hits a database or API setTimeout(function() { let user = { id: userId, name: "Brandon" }; callback(user); }, 1000); // Simulates 1-second delay}
fetchUserData(42, function(user) { console.log("User loaded:", user.name);});
console.log("This runs first because fetchUserData doesn't block!");Output:
This runs first because fetchUserData doesn't block!User loaded: BrandonThe callback lets you say “when the data arrives, do this with it”—without freezing your entire program while waiting.
A More Useful Example: Language Greetings
function sayHello(language, callback) { let greetings = { "English": "Hello", "French": "Bonjour", "German": "Guten Tag", "Spanish": "Hola" };
let greeting = greetings[language];
if (greeting) { callback(greeting); } else { callback("Hello"); // Default to English }}
sayHello("German", function(greeting) { console.log(greeting + ", World!");});
// Output: Guten Tag, World!Now sayHello doesn’t need to know what to do with the greeting—it just finds it and hands it off to your callback.
Callbacks with Error Handling
Real-world callbacks often use the “error-first” pattern:
function getUserById(id, callback) { setTimeout(function() { if (id < 1) { callback("Invalid user ID", null); } else { callback(null, { id: id, name: "Brandon" }); } }, 500);}
getUserById(0, function(error, user) { if (error) { console.error("Error:", error); } else { console.log("User:", user.name); }});Convention: the first parameter is error (null if no error), the second is the actual data.
What I Learned / Gotchas
Callback hell is real. Nesting callbacks gets ugly fast:
getData(function(data) { processData(data, function(processed) { saveData(processed, function(result) { sendEmail(result, function(status) { console.log("Finally done!"); }); }); });});This is why Promises and async/await were invented. But you need to understand callbacks first—Promises use them under the hood.
this gets weird in callbacks. If you’re using callbacks with object methods, this won’t be what you expect:
let user = { name: "Brandon", greet: function(callback) { callback(this.name); }};
// This worksuser.greet(function(name) { console.log("Hello,", name);});
// But this might not (depending on context)let greetFunc = user.greet;greetFunc(function(name) { console.log("Hello,", name); // `this.name` is undefined});Use .bind(this) or arrow functions to fix it.
Arrow functions changed callbacks. Modern JavaScript uses arrow functions for cleaner syntax:
sayHello("French", (greeting) => { console.log(greeting + ", World!");});Same behavior, less typing.
Real-World Use Cases
You’ve been using callbacks without realizing it:
// Array methods use callbacks[1, 2, 3].map(function(num) { return num * 2;});
// Event listeners use callbacksbutton.addEventListener('click', function() { console.log("Button clicked!");});
// setTimeout uses callbackssetTimeout(function() { console.log("One second later...");}, 1000);All of these: you pass a function, and something else calls it later.
Going Further
Callbacks are foundational, but modern JavaScript has better tools for async code:
Promises (ES6):
fetch('/api/user') .then(response => response.json()) .then(user => console.log(user)) .catch(error => console.error(error));Async/Await (ES2017):
async function getUser() { let response = await fetch('/api/user'); let user = await response.json(); console.log(user);}But these are just syntactic sugar over callbacks. Understanding callbacks means you understand what Promises and async/await are doing under the hood.
Resources
- Full code examples on GitHub Gist
- MDN: Callback Functions
- You Don’t Know JS: Async & Performance (deep dive)
Questions? I’m @brandontillman everywhere.