🌙
☀️ Dark
PART 20

Asynchronous Backend Systems

Queues, workers, dead-letter, event-driven architecture.

Advanced 45 min read

Chapter Title

Volume 20: Asynchronous Backend Systems

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

  • Job: A JSON payload describing the work (e.g., { "type": "encode", "videoId": 123 }).
  • Queue: A data structure (usually a FIFO queue stored in Redis or RabbitMQ) holding pending jobs.
  • Worker: A separate Node.js process (or fleet of servers) whose only job is to pop items from the Queue and execute them.

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Learning Objectives

  • Understand why synchronous HTTP requests fail at scale for long-running tasks.
  • Master the architecture of Queues, Workers, and Jobs.
  • Implement robust failure handling with Retries, Exponential Backoff, and Dead-Letter Queues (DLQ).
  • Design idempotent background jobs.
  • Differentiate between Message Brokers, Event-Driven Architecture, and Distributed Workflows.
  • Embrace Eventual Consistency in distributed systems.

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

  • Job: A JSON payload describing the work (e.g., { "type": "encode", "videoId": 123 }).
  • Queue: A data structure (usually a FIFO queue stored in Redis or RabbitMQ) holding pending jobs.
  • Worker: A separate Node.js process (or fleet of servers) whose only job is to pop items from the Queue and execute them.

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Prerequisites

  • Solid understanding of Node.js and TypeScript.
  • Familiarity with synchronous REST APIs (Express/Fastify).
  • Basic knowledge of Redis and PostgreSQL.

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

  • Job: A JSON payload describing the work (e.g., { "type": "encode", "videoId": 123 }).
  • Queue: A data structure (usually a FIFO queue stored in Redis or RabbitMQ) holding pending jobs.
  • Worker: A separate Node.js process (or fleet of servers) whose only job is to pop items from the Queue and execute them.

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Why Does This Exist?

In a standard web request lifecycle, the user's browser opens a connection to the server, waits for the server to process the request, and then receives a response. But what if the processing takes 5 minutes? What if it involves encoding a 4K video, generating a 100-page PDF report, or sending 10,000 emails?

Asynchronous backend systems exist to decouple the acknowledgment of a task from the execution of a task. They allow servers to say, "I got your request, I'll do it in the background, you can go do something else now."

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

  • Job: A JSON payload describing the work (e.g., { "type": "encode", "videoId": 123 }).
  • Queue: A data structure (usually a FIFO queue stored in Redis or RabbitMQ) holding pending jobs.
  • Worker: A separate Node.js process (or fleet of servers) whose only job is to pop items from the Queue and execute them.

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

The Problem Before the Solution

The naive approach to handling heavy tasks in an API endpoint looks like this:

javascript
app.post('/upload-video', async (req, res) => {
  const video = req.file;
  // Naive: Doing heavy work synchronously
  await encodeVideoToAllFormats(video); 
  await uploadToS3(video);
  await notifyUser(video.ownerId);
  
  res.send({ status: 'done' });
});

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

  • Job: A JSON payload describing the work (e.g., { "type": "encode", "videoId": 123 }).
  • Queue: A data structure (usually a FIFO queue stored in Redis or RabbitMQ) holding pending jobs.
  • Worker: A separate Node.js process (or fleet of servers) whose only job is to pop items from the Queue and execute them.

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Why the Old Approach Breaks

new Worker('charge-card', async job => {
  const { userId, amount, idempotencyKey } = job.data;
  
  // 1. Check if already processed
  const existingCharge = await db.charges.findOne({ idempotencyKey });
  if (existingCharge) return; // Exit early!
  
  // 2. Perform charge passing key to Stripe
  await stripe.charges.create({ amount, idempotencyKey });
  
  // 3. Save to our DB
  await db.charges.create({ userId, amount, idempotencyKey });
});

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Production Implementation

In production, you don't run Workers in the same container as your API. You have:

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Production Usage

We use tools like Bull-Board (a UI for BullMQ) to monitor queues. Engineers look at the DLQ daily to find systemic bugs. We also use Event-Driven Architecture (like Kafka) when we need multiple different systems to react to one event (Pub/Sub), rather than a single queue.

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Performance

Queues are extremely fast. Redis can handle hundreds of thousands of queue operations per second. The bottleneck is always your Worker's execution speed. To improve throughput, increase worker concurrency (new Worker(..., { concurrency: 50 })), but ensure you don't exhaust your database connection pool.

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Best Practices

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Interview Questions (Easy, Medium, Hard, Senior)

Easy: What is the main benefit of using a message queue?

Decoupling. It allows the web server to respond to the user immediately while heavy tasks are processed asynchronously in the background.

Medium: What is a Dead-Letter Queue (DLQ)?

A DLQ is a secondary queue where messages/jobs are sent if they cannot be processed successfully after a maximum number of retries. It isolates bad jobs so they don't block the main queue and allows engineers to debug them.

Hard: Explain exactly how an idempotent background job prevents double charging a user.

A unique identifier (idempotency key) is generated when the job is created. The worker checks if an action for this key has already been completed (via a DB lookup or passing the key to a 3rd party like Stripe). If the job fails halfway and retries, the idempotency check prevents the side effect from executing a second time.

Senior: In an eventually consistent, event-driven microservices architecture, how do you handle a distributed transaction spanning three different services without two-phase commit?

Using the Saga Pattern. Each service executes its local transaction and publishes an event. The next service listens to that event and executes its local transaction. If a service fails, it publishes a failure event, which triggers preceding services to execute compensating transactions (undo operations) to revert the system to a consistent state.

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Engineering Challenge

Implement a Scheduled Job System. A user wants an email sent exactly 30 days after they sign up. Design a system that doesn't just sleep for 30 days in memory, but reliably triggers a job 30 days from now, surviving server restarts.

Solution Approach

Use a delayed queue (like BullMQ's delay option) which uses Redis sorted sets to score jobs by timestamp. A polling mechanism checks for jobs whose timestamp is <= now, and moves them to the active queue.

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Revision Sheet

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

Connections

This chapter connects deeply to the Database chapter (handling DB connections in workers), the Microservices chapter (using event buses to communicate), and the DevOps chapter (deploying and scaling worker fleets independently of web servers).

1. HTTP Timeouts: Browsers, reverse proxies (Nginx), and load balancers have timeout limits (usually 30-60 seconds). If encoding takes 5 minutes, the connection will drop. The user sees an error, even though the server might still be working.

2. Thread/Memory Exhaustion: Node.js is single-threaded for event looping. Heavy synchronous work blocks the event loop, preventing other users from being served. Even if you use child processes, keeping thousands of HTTP connections open waiting for tasks to finish will quickly exhaust server memory and file descriptors.

3. Unreliable Executions: If the server crashes on step 2 (upload to S3), the video is lost forever. There is no automatic retry mechanism.

▶ View Solution

Solution implementation.

History

Early batch processing used simple cron jobs reading rows from a SQL database where `status = 'pending'`. As scale grew, polling the database became a bottleneck (the "thundering herd" problem and database lock contention). This led to the birth of dedicated Message Brokers (like RabbitMQ and Apache Kafka) and in-memory queue systems (like Redis/Resque) designed specifically for high-throughput, low-latency task distribution.

Mental Model (Analogy -> Reality)

The Analogy: A Busy Restaurant Kitchen

Imagine a restaurant where the waiter (API) takes your order and then goes into the kitchen to cook the food themselves, making you wait at the table without confirming they got the order. This is the naive synchronous approach.

In a proper restaurant, the waiter writes the order on a ticket (Job), places it on a rotating ticket wheel (Queue), and immediately returns to you saying "Your order is in!" (Acknowledgment). Meanwhile, specialized cooks (Workers) pull tickets off the wheel one by one and cook the food asynchronously.

The Reality

Internal Working (Memory, stack, process, network, etc.)

When you enqueue a job using Redis (e.g., via BullMQ), the API process sends a network command (LPUSH or an atomic Lua script) to the Redis server. The API process then immediately returns the HTTP response.

In a completely different process—perhaps on a different server—a Worker uses a blocking pop network command (like BRPOP) to wait for new items in Redis. When a job arrives, Redis instantly sends the payload over the network to the Worker. The Worker parses the JSON, pushes function calls onto its call stack, and processes the job. If the Worker crashes, Redis (via mechanisms like BullMQ's active sets and locks) notices the lock expired and puts the job back in the queue for another worker.

Visual Explanation (ASCII diagrams)

typescript
▶ View Solution

Solution implementation.


Client       API Server (Producer)         Redis (Queue)           Worker Server (Consumer)
  |                 |                            |                            |
  |-- 1. HTTP POST->|                            |                            |
  |                 |-- 2. Push Job JSON ------->|                            |
  |<- 3. 202 OK ----|                            |-- 4. Pops Job JSON ------->|
  |                 |                            |                            |
  |                 |                            |<- 5. Acknowledges start ---|
  |                 |                            |                            |
  |                 |                            |    [ Processing... ]       |
  |                 |                            |                            |
  |                 |                            |<- 6. Marks completed ------|
  
▶ View Solution

Solution implementation.

Syntax

Using BullMQ with TypeScript and Redis:

yaml
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

// 1. Create a Queue
const videoQueue = new Queue('videoProcessing', { connection: redisOptions });

// 2. Add a Job (Producer)
await videoQueue.add('encode', { videoId: 123, format: 'mp4' });

// 3. Process the Job (Consumer/Worker)
const worker = new Worker('videoProcessing', async job => {
  const { videoId, format } = job.data;
  await encodeVideo(videoId, format);
}, { connection: redisOptions });
▶ View Solution

Solution implementation.

Tiny Example

Let's build a reliable email sender.

javascript
▶ View Solution

Solution implementation.

import { Queue, Worker } from 'bullmq';

const emailQueue = new Queue('emails');

// API Endpoint
app.post('/signup', async (req, res) => {
  const user = await db.users.create(req.body);
  
  // Enqueue job, don't wait for email to send!
  await emailQueue.add('welcome-email', { email: user.email });
  
  res.status(202).send({ message: 'Signed up!' });
});

// Worker Process (run in a separate terminal)
new Worker('emails', async job => {
  console.log(`Sending email to ${job.data.email}...`);
  await sendGrid.send({ to: job.data.email, template: 'welcome' });
});
▶ View Solution

Solution implementation.

Walkthrough

In our Tiny Example, when a user hits /signup, the DB record is created fast (milliseconds). The email sending via a 3rd party API (SendGrid) might take 500ms to 2 seconds, and it might fail due to network blips.

By putting the job in the emails queue, the HTTP response goes back instantly. The user sees a snappy UI. The Worker process, which runs independently, picks up the job. If SendGrid is down, the Worker can fail the job, and the Queue system will automatically retry it later.

Break It

What happens if our worker function looks like this, and sendGrid.send fails?

javascript
▶ View Solution

Solution implementation.

new Worker('emails', async job => {
  await sendGrid.send({ to: job.data.email, template: 'welcome' }); // <-- THROWS ERROR
});
▶ View Solution

Solution implementation.

If there is no error handling, the worker crashes. By default, BullMQ catches the exception, marks the job as failed, and moves it to a failed set. But the email is never sent. The user never gets their welcome email.

Debug It

To fix this, we need Retries and Exponential Backoff.

javascript
▶ View Solution

Solution implementation.

// Producer side
await emailQueue.add('welcome-email', { email: user.email }, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000 // 1s, 2s, 4s, 8s, 16s
  }
});
▶ View Solution

Solution implementation.

Now, if the first attempt fails, it retries in 1 second. If that fails, it waits 2 seconds, then 4, etc. If it fails 5 times, it is permanently moved to a Dead-Letter Queue (DLQ)—a special holding area for jobs that have chronically failed, allowing human engineers to inspect them and figure out why.

Mini Project (20-30 min)

Build a Webhook Dispatcher

Design a system where users configure webhooks. When an event happens, you must send an HTTP POST to their configured URL. If their server is down, you must retry with backoff. If it fails after 10 tries, mark the webhook as "disabled" in the database.

Real Application Feature

Idempotent Payments Processing

When a background job retries, you have a massive risk: what if the job actually succeeded, but the network failed during the success acknowledgment, causing it to retry? For email, they get two emails (annoying). For payments, they get charged twice (catastrophic).

We solve this with Idempotency. An operation is idempotent if doing it multiple times has the same result as doing it once.

javascript
▶ View Solution

Solution implementation.

Bigger Project (1-2 hours)

Set up a BullMQ queue with Redis. Create a producer that adds background email jobs and a worker that processes them with retries.

▶ View Solution
typescript
// Implementation for Async Backend Systems
console.log("Bigger project solution");

Interview Questions

PreviousVolume 19 🏠 Curriculum NextVolume 21
🏠 Curriculum NextVolume 2