Computer Science

Distributed Systems from Scratch: Building a Scalable Task Queue

Scale your applications beyond a single server. Learn the core patterns of distributed architectures by building, containerizing, and scaling an event-driven task processing system with an API Gateway, background workers, and shared caching.

Build it yourself, get guided when you are stuck, and leave with proof you can actually show.

Right now, our backend is a single block. If a user asks our server to do a heavy, slow task, the entire server freezes up and other users can't even load the homepage.

Did You Know? Netflix's open‑source API gateway, Zuul, routes billions of requests each day and can handle over 100 million requests per minute. It powers Netflix's edge layer, demonstrating how a well‑designed gateway can scale to massive traffic while keeping services isolated.

To fix this, we are splitting the backend into two separate services:

  1. The Gateway (running on port 5000): The public face. It handles incoming requests quickly and hands off heavy work.
  2. The Processor (running on port 5001): The behind-the-scenes worker. It does the heavy lifting and returns the result to the Gateway.

Since you already have a monorepo setup, we can run these side-by-side. Let's build and wire them together.

Step 1: Set up the Processor (Port 5001)

This service will live in apps/processor/src/index.ts. Its only job is to receive raw data, process it, and return the result.

Tasks

Open apps/processor/src/index.ts and set up the Express route. You need to write the logic that takes a string from the request body, converts it to uppercase, and sends it back.

import express from 'express';

const app = express();
const PORT = 5001;

app.use(express.json());

// This endpoint receives the raw data to process
app.post('/jobs', (req, res) => {
  const { data } = req.body;

  if (!data) {
    return res.status(400).json({ error: 'No data provided' });
  }

  // TODO: Transform the 'data' string to uppercase
  const processedData = // ... your code here ...

  res.json({
    status: 'completed',
    result: processedData,
    processedAt: new Date().toISOString()
  });
});

app.listen(PORT, () => {
  console.log(`Processor service running on port ${PORT}`);
});

Step 2: Set up the Gateway (Port 5000)

Now let’s build the Gateway in apps/gateway/src/index.ts.

The Gateway will accept a client request at /work, extract the payload, and use fetch to pass it off to the Processor service on port 5001. Once the Processor responds, the Gateway sends that result back to the user.

Tasks

Complete the fetch call below to connect the two services.

import express from 'express';

const app = express();
const PORT = 5000;

app.use(express.json());

app.post('/work', async (req, res) => {
  const { payload } = req.body;

  if (!payload) {
    return res.status(400).json({ error: 'No payload provided' });
  }

  try {
    // TODO: Use native fetch to send a POST request to the Processor's /jobs endpoint.
    // The Processor is running on port 5001.
    const response = await fetch('/* fill in the processor URL */', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ data: payload }),
    });

    if (!response.ok) {
      throw new Error(`Processor returned status ${response.status}`);
    }

    // TODO: Parse the JSON response from the Processor
    const data = await // ... your code here ...

    // Send the processed result back to the client
    res.json({
      message: 'Gateway successfully routed the job',
      processorResponse: data,
    });
  } catch (error) {
    console.error('Error contacting processor:', error);
    res.status(502).json({ error: 'Processor service is unreachable' });
  }
});

app.listen(PORT, () => {
  console.log(`Gateway service running on port ${PORT}`);
});

Tasks

Step 3: Test the Connection

To verify that your services are talking to each other, we need to run both at the same time.

  1. Open two terminal windows.

  2. In the first terminal, start the Processor:

    npm run dev --workspace=processor
    

    (Or your monorepo's equivalent run command, ensuring it runs on port 5001)

  3. In the second terminal, start the Gateway:

    npm run dev --workspace=gateway
    

    (Ensuring it runs on port 5000)

Tasks

Now, send a request to the Gateway (port 5000) using curl in a third terminal, or your preferred API client (like Postman/Thunder Client):

curl -X POST http://localhost:5000/work \
     -H "Content-Type: application/json" \
     -d '{"payload": "hello multi-service world"}'

What to look for:

If everything is wired correctly, you should receive a response back from port 5000 containing the uppercase version of your payload, generated by the service on port 5001:

{
  "message": "Gateway successfully routed the job",
  "processorResponse": {
    "status": "completed",
    "result": "HELLO MULTI-SERVICE WORLD",
    "processedAt": "2026-02-05..."
  }
}

If you get a 502 error, double-check that your Gateway's fetch URL is pointing exactly to http://localhost:5001/jobs.

How this build unfolds

From Monolith to Microservices: API Gateway & Worker Isolation

Deconstruct a single server into dedicated services to handle high traffic and isolated workloads safely.

Asynchronous Decoupling with Message Queues

Transition from blocking HTTP calls to a non-blocking, event-driven architecture using message queues.

Containerization and Horizontal Scaling

Package your distributed services and scale them horizontally to handle massive parallel workloads.

Distributed Resilience: Caching and Fault Tolerance

Protect your system against failure, handle network partitions, and optimize reads with shared caches.

Learn by building your own version.

Remix this public project to open the workspace, follow the guided build, and let the AI mentor teach you through the work instead of doing it for you.