Skip to main content

Module 16: Asynchronous JavaScript - Callbacks

JavaScript is single-threaded but handles asynchronous operations through the event loop. Understanding callbacks is the first step to mastering async programming.


1. Understanding Synchronous vs Asynchronous

1.1 Synchronous Code (Blocking)

console.log('Start');

// Blocking operation
function heavyCalculation() {
let sum = 0;
for (let i = 0; i < 1000000000; i++) {
sum += i;
}
return sum;
}

const result = heavyCalculation(); // Blocks execution
console.log('Result:', result);
console.log('End');

// Output (in order):
// Start
// (wait...)
// Result: ...
// End

1.2 Asynchronous Code (Non-blocking)

console.log('Start');

setTimeout(() => {
console.log('Async operation');
}, 1000);

console.log('End');

// Output:
// Start
// End
// (after 1 second)
// Async operation
Single Thread

JavaScript runs on a single thread, but async operations don't block the thread. They're handled by browser APIs and return via the event loop.


2. What are Callbacks?

A callback is a function passed as an argument to another function and executed after some operation completes.

2.1 Synchronous Callbacks

// Array methods use callbacks
const numbers = [1, 2, 3, 4, 5];

numbers.forEach(function(num) {
console.log(num);
});

const doubled = numbers.map(function(num) {
return num * 2;
});

// Custom synchronous callback
function greet(name, callback) {
console.log('Hello, ' + name);
callback();
}

greet('John', function() {
console.log('Callback executed');
});

2.2 Asynchronous Callbacks

// setTimeout
setTimeout(function() {
console.log('Executed after 1 second');
}, 1000);

// Event listeners
button.addEventListener('click', function() {
console.log('Button clicked');
});

// AJAX with callbacks (old style)
function fetchData(url, callback) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);

xhr.onload = function() {
if (xhr.status === 200) {
callback(null, JSON.parse(xhr.responseText));
} else {
callback(new Error('Request failed'));
}
};

xhr.send();
}

3. Common Async Patterns

3.1 setTimeout

// Execute after delay
setTimeout(function() {
console.log('Delayed execution');
}, 2000);

// With arrow function
setTimeout(() => {
console.log('Arrow function callback');
}, 1000);

// Store timer ID
const timerId = setTimeout(() => {
console.log('This will be cancelled');
}, 3000);

// Cancel timeout
clearTimeout(timerId);

3.2 setInterval

// Execute repeatedly
const intervalId = setInterval(function() {
console.log('Executing every second');
}, 1000);

// Stop after 5 seconds
setTimeout(() => {
clearInterval(intervalId);
console.log('Interval stopped');
}, 5000);

// Countdown example
let count = 10;
const countdownId = setInterval(() => {
console.log(count);
count--;

if (count < 0) {
clearInterval(countdownId);
console.log('Done!');
}
}, 1000);

3.3 Event Listeners

const button = document.querySelector('#myButton');

// Add callback
button.addEventListener('click', function(event) {
console.log('Clicked at:', event.clientX, event.clientY);
});

// Named callback (can be removed)
function handleClick(event) {
console.log('Button clicked');
}

button.addEventListener('click', handleClick);

// Remove callback
button.removeEventListener('click', handleClick);

3.4 File Reading (Node.js)

const fs = require('fs');

// Async file read with callback
fs.readFile('data.txt', 'utf8', function(error, data) {
if (error) {
console.error('Error reading file:', error);
return;
}
console.log('File content:', data);
});

console.log('This runs before file is read');

4. Error-First Callbacks (Node.js Convention)

4.1 Pattern

function doSomethingAsync(param, callback) {
setTimeout(() => {
if (param === 'error') {
callback(new Error('Something went wrong'), null);
} else {
callback(null, 'Success: ' + param);
}
}, 1000);
}

// Usage
doSomethingAsync('test', function(error, result) {
if (error) {
console.error('Error:', error.message);
return;
}
console.log('Result:', result);
});

4.2 Real Example: Database Query

function queryDatabase(query, callback) {
// Simulate database query
setTimeout(() => {
const success = Math.random() > 0.5;

if (success) {
callback(null, [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]);
} else {
callback(new Error('Database connection failed'), null);
}
}, 1000);
}

// Usage
queryDatabase('SELECT * FROM users', function(error, results) {
if (error) {
console.error('Query failed:', error.message);
return;
}

console.log('Users:', results);
});

5. Callback Hell (Pyramid of Doom)

5.1 The Problem

// Nested callbacks become unreadable
getUserData(userId, function(error, user) {
if (error) {
console.error(error);
return;
}

getOrders(user.id, function(error, orders) {
if (error) {
console.error(error);
return;
}

getOrderDetails(orders[0].id, function(error, details) {
if (error) {
console.error(error);
return;
}

processPayment(details.total, function(error, payment) {
if (error) {
console.error(error);
return;
}

sendConfirmation(user.email, payment, function(error, sent) {
if (error) {
console.error(error);
return;
}

console.log('Order completed!');
});
});
});
});
});

5.2 Solutions

1. Named Functions

function handleUser(error, user) {
if (error) return console.error(error);
getOrders(user.id, handleOrders);
}

function handleOrders(error, orders) {
if (error) return console.error(error);
getOrderDetails(orders[0].id, handleDetails);
}

function handleDetails(error, details) {
if (error) return console.error(error);
processPayment(details.total, handlePayment);
}

function handlePayment(error, payment) {
if (error) return console.error(error);
console.log('Payment successful:', payment);
}

getUserData(userId, handleUser);

2. Modularization

function processOrder(userId) {
getUserData(userId, function(error, user) {
if (error) return handleError(error);

fetchOrderData(user, function(error, orderData) {
if (error) return handleError(error);

completeOrder(orderData);
});
});
}

function fetchOrderData(user, callback) {
getOrders(user.id, function(error, orders) {
if (error) return callback(error);

getOrderDetails(orders[0].id, callback);
});
}

function completeOrder(details) {
console.log('Order completed:', details);
}

function handleError(error) {
console.error('Error:', error);
}
Callback Hell

Deeply nested callbacks are hard to read, debug, and maintain. This led to the development of Promises and async/await.


6. Creating Custom Async Functions

6.1 Simple Async Function

function delay(ms, callback) {
setTimeout(callback, ms);
}

// Usage
delay(1000, function() {
console.log('Executed after 1 second');
});

6.2 Async Function with Parameters

function fetchUser(id, callback) {
setTimeout(() => {
const users = {
1: { id: 1, name: 'John' },
2: { id: 2, name: 'Jane' }
};

const user = users[id];

if (user) {
callback(null, user);
} else {
callback(new Error('User not found'), null);
}
}, 1000);
}

// Usage
fetchUser(1, function(error, user) {
if (error) {
console.error(error.message);
} else {
console.log('User:', user);
}
});

6.3 Multiple Callbacks

function processData(data, onSuccess, onError) {
setTimeout(() => {
if (!data) {
onError(new Error('No data provided'));
} else {
onSuccess(data.toUpperCase());
}
}, 1000);
}

// Usage
processData(
'hello',
function(result) {
console.log('Success:', result);
},
function(error) {
console.error('Error:', error.message);
}
);

7. Practical Examples

7.1 Animation with Callbacks

function animate(element, property, from, to, duration, callback) {
const start = Date.now();
const timer = setInterval(() => {
const elapsed = Date.now() - start;
const progress = Math.min(elapsed / duration, 1);

const value = from + (to - from) * progress;
element.style[property] = value + 'px';

if (progress === 1) {
clearInterval(timer);
if (callback) callback();
}
}, 16); // ~60fps
}

// Usage
const box = document.querySelector('.box');

animate(box, 'left', 0, 200, 1000, function() {
console.log('Animation complete');

animate(box, 'top', 0, 200, 1000, function() {
console.log('Second animation complete');
});
});

7.2 Sequential Execution

function runSequence(tasks, finalCallback) {
let index = 0;

function runNext() {
if (index >= tasks.length) {
if (finalCallback) finalCallback();
return;
}

const task = tasks[index++];
task(function() {
runNext();
});
}

runNext();
}

// Usage
const tasks = [
function(callback) {
setTimeout(() => {
console.log('Task 1 complete');
callback();
}, 1000);
},
function(callback) {
setTimeout(() => {
console.log('Task 2 complete');
callback();
}, 500);
},
function(callback) {
setTimeout(() => {
console.log('Task 3 complete');
callback();
}, 800);
}
];

runSequence(tasks, function() {
console.log('All tasks complete');
});

7.3 Retry Logic

function retry(fn, maxAttempts, delay, callback) {
let attempts = 0;

function attempt() {
attempts++;

fn(function(error, result) {
if (error && attempts < maxAttempts) {
console.log(`Attempt ${attempts} failed, retrying...`);
setTimeout(attempt, delay);
} else if (error) {
callback(error, null);
} else {
callback(null, result);
}
});
}

attempt();
}

// Usage
function unreliableOperation(callback) {
const success = Math.random() > 0.7;

setTimeout(() => {
if (success) {
callback(null, 'Success!');
} else {
callback(new Error('Operation failed'));
}
}, 500);
}

retry(unreliableOperation, 3, 1000, function(error, result) {
if (error) {
console.error('All attempts failed:', error.message);
} else {
console.log('Operation succeeded:', result);
}
});

7.4 Parallel Execution

function parallel(tasks, callback) {
let completed = 0;
const results = [];
let hasError = false;

if (tasks.length === 0) {
callback(null, results);
return;
}

tasks.forEach((task, index) => {
task(function(error, result) {
if (hasError) return;

if (error) {
hasError = true;
callback(error, null);
return;
}

results[index] = result;
completed++;

if (completed === tasks.length) {
callback(null, results);
}
});
});
}

// Usage
const parallelTasks = [
function(callback) {
setTimeout(() => callback(null, 'Result 1'), 1000);
},
function(callback) {
setTimeout(() => callback(null, 'Result 2'), 500);
},
function(callback) {
setTimeout(() => callback(null, 'Result 3'), 800);
}
];

parallel(parallelTasks, function(error, results) {
if (error) {
console.error('Error:', error);
} else {
console.log('All results:', results);
}
});

8. The Event Loop

8.1 How It Works

console.log('1: Synchronous');

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

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

console.log('4: Synchronous');

// Output:
// 1: Synchronous
// 4: Synchronous
// 3: Promise (Microtask)
// 2: setTimeout (Macrotask)
Call Stack, Task Queue, Microtask Queue
  1. Call Stack – Executes synchronous code
  2. Microtask Queue – Promises, queueMicrotask
  3. Macrotask Queue – setTimeout, setInterval, I/O

8.2 Visualization

console.log('Start');

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

Promise.resolve()
.then(() => console.log('Promise 1'))
.then(() => console.log('Promise 2'));

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

console.log('End');

// Execution order:
// 1. Call Stack: Start, End
// 2. Microtasks: Promise 1, Promise 2
// 3. Macrotasks: Timeout 1, Timeout 2

9. Best Practices

9.1 Always Handle Errors

// ❌ Bad
doSomethingAsync(data, function(error, result) {
console.log(result); // Crash if error!
});

// ✅ Good
doSomethingAsync(data, function(error, result) {
if (error) {
console.error('Error:', error);
return;
}
console.log('Result:', result);
});

9.2 Avoid Callback Hell

// ❌ Bad: Deep nesting
getData(function(data) {
processData(data, function(processed) {
saveData(processed, function(saved) {
// ...
});
});
});

// ✅ Good: Use named functions or Promises

9.3 Use Meaningful Names

// ❌ Bad
doAsync('data', function(e, r) {
// What are e and r?
});

// ✅ Good
doAsync('data', function(error, result) {
if (error) {
handleError(error);
} else {
processResult(result);
}
});

10. Transition to Promises

10.1 Callback to Promise Wrapper

// Callback-based function
function fetchDataCallback(url, callback) {
setTimeout(() => {
callback(null, { data: 'result' });
}, 1000);
}

// Promisify
function fetchDataPromise(url) {
return new Promise((resolve, reject) => {
fetchDataCallback(url, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}

// Usage
fetchDataPromise('https://api.example.com')
.then(result => console.log(result))
.catch(error => console.error(error));

Summary

In this module, you learned:

  • ✅ Difference between synchronous and asynchronous code
  • ✅ What callbacks are and how they work
  • ✅ Common async patterns: setTimeout, setInterval, events
  • ✅ Error-first callback convention
  • ✅ Callback hell and how to avoid it
  • ✅ Creating custom async functions
  • ✅ The event loop and task queues
  • ✅ Best practices for working with callbacks
  • ✅ Transitioning from callbacks to Promises
Next Steps

In Module 17, you'll learn about Promises, a more elegant solution to async programming that solves callback hell.


Practice Exercises

  1. Create a traffic light simulation using setTimeout callbacks
  2. Build a retry mechanism for failed operations
  3. Implement a queue that processes tasks sequentially
  4. Create an animation library using callbacks
  5. Build a simple polling mechanism
  6. Implement a timeout wrapper for async functions
  7. Create a waterfall pattern for sequential async tasks
  8. Build a rate limiter using callbacks

Additional Resources