From Zero to AI

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:

  1. "A" - synchronous, runs immediately
  2. First setTimeout registered (1000ms)
  3. Second setTimeout registered (0ms)
  4. "D" - synchronous, runs immediately
  5. Stack empty, "C" callback runs (was 0ms)
  6. 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:

  1. Waits 1 second, then prints "First"
  2. Then waits 1 second, then prints "Second"
  3. 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

  1. Synchronous code runs line by line, blocking until complete
  2. Asynchronous code allows other code to run while waiting
  3. Callbacks are functions called when async operations complete
  4. Error-first callbacks put the error as the first parameter
  5. Callback hell happens with deeply nested callbacks (we will fix this later!)
  6. Use async for I/O operations (network, files, database)
  7. 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.

Continue to Lesson 1.4: Visualizing the Event Loop