Lesson 1.3: Synchronous vs Asynchronous Code
Duration: 60 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Clearly distinguish between synchronous and asynchronous code
- Understand blocking vs non-blocking operations
- Identify when to use synchronous vs asynchronous patterns
- Write basic asynchronous code using callbacks
- Handle errors in asynchronous operations
Synchronous Code
Synchronous code executes line by line, in order. Each operation must complete before the next one starts.
const first = 'Hello';
const second = 'World';
const result = first + ' ' + second;
console.log(result); // "Hello World"
This is straightforward and predictable. You always know exactly what happens when.
Characteristics of Synchronous Code
console.log('Step 1');
console.log('Step 2');
console.log('Step 3');
// Always outputs:
// Step 1
// Step 2
// Step 3
- Executes top to bottom
- Each line waits for the previous one
- Predictable order
- Can block if operations are slow
The Blocking Problem
Synchronous code blocks when it encounters slow operations:
function readLargeFile(): string {
// Imagine this takes 3 seconds
// During this time, NOTHING else can run
return 'file contents...';
}
console.log('Starting...');
const data = readLargeFile(); // Program freezes for 3 seconds
console.log('File read!'); // Only prints after 3 seconds
console.log('Ready to continue');
While readLargeFile runs:
- User input is ignored
- Animations stop
- The interface freezes
- Other code cannot run
This creates a poor user experience.
Real-World Impact
In a browser:
// This freezes the entire page!
function calculatePrimes(max: number): number[] {
const primes: number[] = [];
for (let n = 2; n <= max; n++) {
let isPrime = true;
for (let i = 2; i < n; i++) {
if (n % i === 0) {
isPrime = false;
break;
}
}
if (isPrime) primes.push(n);
}
return primes;
}
// User clicks a button
const result = calculatePrimes(1000000); // Page unresponsive!
Asynchronous Code
Asynchronous code allows other operations to continue while waiting for slow tasks to complete.
console.log('Starting...');
setTimeout(() => {
console.log('Timer finished!');
}, 2000);
console.log('Continuing immediately!');
Output:
Starting...
Continuing immediately!
Timer finished! // After 2 seconds
The program does not wait for the timer. It continues executing and handles the timer callback later.
Characteristics of Asynchronous Code
- Does not block the main thread
- Results come back later via callbacks
- Order is not always top-to-bottom
- Allows multiple operations to "happen" simultaneously
Callbacks: The First Async Pattern
A callback is a function passed to another function, to be called later when an operation completes.
function fetchUserData(
userId: string,
callback: (user: { name: string; email: string }) => void
): void {
// Simulate network delay
setTimeout(() => {
const user = { name: 'Alice', email: 'alice@example.com' };
callback(user); // Call the callback with the result
}, 1000);
}
console.log('Fetching user...');
fetchUserData('123', (user) => {
console.log('Got user:', user.name);
});
console.log('Request sent!');
Output:
Fetching user...
Request sent!
Got user: Alice // After 1 second
The Callback Pattern
// Pattern: operation(input, callback)
// callback is called when operation completes
function doSomething(input: string, onComplete: (result: string) => void): void {
setTimeout(() => {
const result = input.toUpperCase();
onComplete(result);
}, 500);
}
doSomething('hello', (result) => {
console.log(result); // "HELLO"
});
Comparing Sync vs Async
Let us see the same operation done both ways:
Synchronous Version (Blocking)
function syncReadFiles(): void {
console.log('Reading file 1...');
const file1 = 'Contents of file 1'; // Imagine this takes 1 second
console.log('File 1 done');
console.log('Reading file 2...');
const file2 = 'Contents of file 2'; // Another 1 second
console.log('File 2 done');
console.log('All files read!');
}
// Total time: 2 seconds (sequential)
Asynchronous Version (Non-Blocking)
function asyncReadFiles(): void {
console.log('Starting file reads...');
setTimeout(() => {
console.log('File 1 done');
}, 1000);
setTimeout(() => {
console.log('File 2 done');
}, 1000);
console.log('Requests sent!');
}
// Total time: ~1 second (parallel)
Output:
Starting file reads...
Requests sent!
File 1 done // After 1 second
File 2 done // After 1 second (same time!)
Both files are read in parallel, saving time.
When Operations Depend on Each Other
Sometimes you need results from one operation before starting another:
function getUser(userId: string, callback: (user: { id: string; name: string }) => void): void {
setTimeout(() => {
callback({ id: userId, name: 'Alice' });
}, 500);
}
function getPosts(userId: string, callback: (posts: string[]) => void): void {
setTimeout(() => {
callback(['Post 1', 'Post 2', 'Post 3']);
}, 500);
}
// We need the user before we can get their posts
getUser('123', (user) => {
console.log('Got user:', user.name);
getPosts(user.id, (posts) => {
console.log('Got posts:', posts);
});
});
This is still asynchronous, but the operations run in sequence because one depends on the other.
Callback Hell
When you nest many callbacks, the code becomes hard to read:
getUser('123', (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0], (comments) => {
getAuthor(comments[0], (author) => {
console.log(author.name);
// This keeps going deeper...
});
});
});
});
This is called callback hell or the "pyramid of doom." We will learn how to solve this with Promises and async/await in the next modules.
Error Handling in Callbacks
Asynchronous operations can fail. The common pattern is "error-first callbacks":
function fetchData(
url: string,
callback: (error: Error | null, data: string | null) => void
): void {
setTimeout(() => {
// Simulate random success/failure
if (Math.random() > 0.5) {
callback(null, 'Success! Here is your data');
} else {
callback(new Error('Network error'), null);
}
}, 1000);
}
fetchData('https://api.example.com', (error, data) => {
if (error) {
console.error('Failed:', error.message);
return;
}
console.log('Data:', data);
});
The convention: first parameter is the error (null if successful), second is the result.
Node.js Style Callbacks
import fs from 'fs';
// Node.js uses error-first callbacks
fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) {
console.error('Could not read file:', err.message);
return;
}
console.log('File contents:', data);
});
Common Asynchronous Operations
Here are operations that are typically asynchronous:
| Operation | Why Async? |
|---|---|
| Network requests (fetch, HTTP) | Network is slow and unpredictable |
| File system access | Disk I/O takes time |
| Database queries | Waiting for database response |
| Timers (setTimeout) | By design - schedule future work |
| User events (clicks, input) | Happen at unpredictable times |
| Animations | Need to update without blocking |
Synchronous vs Asynchronous: When to Use Each
Use Synchronous When:
- Operations are fast (< 1ms)
- Order matters and there are no delays
- Working with simple calculations
- Reading configuration at startup
// Good for sync
const sum = 1 + 2 + 3;
const upper = 'hello'.toUpperCase();
const items = [1, 2, 3].map((x) => x * 2);
Use Asynchronous When:
- Operations involve I/O (network, files, database)
- Operations might be slow
- You want to keep the UI responsive
- Multiple operations can run in parallel
// Good for async
fetch('https://api.example.com/data');
setTimeout(callback, 1000);
readFileFromDisk(path, callback);
Practical Example: Loading User Data
Let us build a realistic example:
interface User {
id: string;
name: string;
email: string;
}
interface Post {
id: string;
title: string;
userId: string;
}
// Simulate API calls
function fetchUser(
userId: string,
callback: (error: Error | null, user: User | null) => void
): void {
console.log('Fetching user...');
setTimeout(() => {
const user: User = {
id: userId,
name: 'Alice Johnson',
email: 'alice@example.com',
};
callback(null, user);
}, 800);
}
function fetchUserPosts(
userId: string,
callback: (error: Error | null, posts: Post[] | null) => void
): void {
console.log('Fetching posts...');
setTimeout(() => {
const posts: Post[] = [
{ id: '1', title: 'First Post', userId },
{ id: '2', title: 'Second Post', userId },
];
callback(null, posts);
}, 600);
}
// Usage
console.log('Starting...');
fetchUser('user-123', (err, user) => {
if (err || !user) {
console.error('Failed to get user');
return;
}
console.log(`User: ${user.name}`);
fetchUserPosts(user.id, (err, posts) => {
if (err || !posts) {
console.error('Failed to get posts');
return;
}
console.log(`Posts: ${posts.length}`);
posts.forEach((post) => {
console.log(` - ${post.title}`);
});
});
});
console.log('Requests initiated!');
Output:
Starting...
Fetching user...
Requests initiated!
User: Alice Johnson
Fetching posts...
Posts: 2
- First Post
- Second Post
Exercises
Exercise 1: Predict the Output
What will this code print and in what order?
console.log('A');
setTimeout(() => {
console.log('B');
}, 1000);
setTimeout(() => {
console.log('C');
}, 0);
console.log('D');
Solution
A
D
C
B
Explanation:
- "A" - synchronous, runs immediately
- First setTimeout registered (1000ms)
- Second setTimeout registered (0ms)
- "D" - synchronous, runs immediately
- Stack empty, "C" callback runs (was 0ms)
- After 1 second, "B" callback runs
Exercise 2: Convert to Async
Convert this blocking code to use callbacks:
// Blocking version
function processData(): string {
const data = 'raw data';
const processed = data.toUpperCase();
return processed;
}
const result = processData();
console.log(result);
Solution
function processDataAsync(callback: (result: string) => void): void {
setTimeout(() => {
const data = 'raw data';
const processed = data.toUpperCase();
callback(processed);
}, 100);
}
processDataAsync((result) => {
console.log(result);
});
Exercise 3: Error Handling
Add error handling to this async function:
function divide(a: number, b: number, callback: (result: number) => void): void {
setTimeout(() => {
const result = a / b;
callback(result);
}, 100);
}
Solution
function divide(
a: number,
b: number,
callback: (error: Error | null, result: number | null) => void
): void {
setTimeout(() => {
if (b === 0) {
callback(new Error('Cannot divide by zero'), null);
return;
}
const result = a / b;
callback(null, result);
}, 100);
}
// Usage
divide(10, 2, (error, result) => {
if (error) {
console.error('Error:', error.message);
return;
}
console.log('Result:', result);
});
divide(10, 0, (error, result) => {
if (error) {
console.error('Error:', error.message); // "Cannot divide by zero"
return;
}
console.log('Result:', result);
});
Exercise 4: Sequential Async Operations
Write code that:
- Waits 1 second, then prints "First"
- Then waits 1 second, then prints "Second"
- Then waits 1 second, then prints "Third"
Solution
console.log('Starting...');
setTimeout(() => {
console.log('First');
setTimeout(() => {
console.log('Second');
setTimeout(() => {
console.log('Third');
console.log('Done!');
}, 1000);
}, 1000);
}, 1000);
console.log('Waiting...');
// Output:
// Starting...
// Waiting...
// First (after 1s)
// Second (after 2s)
// Third (after 3s)
// Done!
Key Takeaways
- Synchronous code runs line by line, blocking until complete
- Asynchronous code allows other code to run while waiting
- Callbacks are functions called when async operations complete
- Error-first callbacks put the error as the first parameter
- Callback hell happens with deeply nested callbacks (we will fix this later!)
- Use async for I/O operations (network, files, database)
- Use sync for fast operations (calculations, string manipulation)
Resources
| Resource | Type | Description |
|---|---|---|
| MDN: Asynchronous JavaScript | Tutorial | Comprehensive async guide |
| JavaScript.info: Callbacks | Tutorial | Callback patterns explained |
| Node.js: Blocking vs Non-Blocking | Documentation | Node.js perspective |
Next Lesson
Now that you understand sync vs async code, let us visualize the event loop in action with interactive examples and tools.