DEV Community

Rick13211
Rick13211

Posted on

Your Backend is Making Users Wait 7 Seconds. Here's the Fix.

Most of the time, unknowingly, a developer-kun uses synchronous APIs where the server expects a response immediately and halts all other work.

This works fine when resources are plentiful and latency is low.

But flip the situation — backend is busy, latency is high. A sync API will wait indefinitely unless programmed otherwise. Bad UX. Angry users. Sad developer-kun.


The Problem — A Real Example

A user signs up on your platform. Your server needs to:

  1. Send a welcome email — 2 seconds
  2. Receive and process their profile picture — 3 seconds
  3. Send a Slack notification — 1 second
  4. Log the event to analytics — 1 second

Total: 7 seconds of the user staring at a loading spinner.

On one server, doing everything synchronously, that's what you get. And that's assuming nothing goes wrong — if the email service is slow that day, add another 5 seconds on top.

The user thinks your app is broken. They leave. You cry.


The Fix — Async Architecture with Queues

Instead of doing everything in the request-response cycle, you:

  1. Save the user to the database
  2. Queue up the heavy work
  3. Return a response immediately
  4. Workers pick up the queued jobs in the background
User signs up
     ↓
Server saves to DB (fast)
     ↓
Queues: welcome email, profile processing, slack notification, analytics
     ↓
Returns response in ~50ms ← user is happy

     Meanwhile, in the background:
     Worker picks up jobs one by one
     Emails sent ->  Slack notified ->  Analytics logged
Enter fullscreen mode Exit fullscreen mode

The user gets their response in 50ms. The work still happens. Nobody waits.


Enter BullMQ

BullMQ is a Node.js job queue library backed by Redis. Think of it as a post office:

  • Producer — your API drops a letter (job) in the mailbox (queue)
  • Queue — holds the letters until a postman is free
  • Worker — the postman who picks up letters and delivers them
npm install bullmq ioredis
Enter fullscreen mode Exit fullscreen mode

The Queue — Your Mailbox

import { Queue } from 'bullmq';
import Redis from 'ioredis';

// Connect to Redis — BullMQ uses Redis to store jobs
const connection = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  maxRetriesPerRequest: null, // required by BullMQ
});

// Define the queue
export const emailQueue = new Queue('emails', { connection });

// Helper to add a welcome email job
export async function queueWelcomeEmail(user) {
  await emailQueue.add(
    'welcome-email',     // job name
    {                    // job data — available to the worker
      userId: user.id,
      email: user.email,
      name: user.name,
    },
    {
      attempts: 3,       // retry up to 3 times if it fails
      backoff: {
        type: 'exponential',
        delay: 2000,     // wait 2s, then 4s, then 8s between retries
      },
      removeOnComplete: 100, // keep last 100 completed jobs in Redis
      removeOnFail: 50,      // keep last 50 failed jobs for inspection
    }
  );
  console.log(`Queued welcome email for ${user.email}`);
}

// Helper to add an account deletion email job
export async function queueAccountDeleteEmail(user) {
  await emailQueue.add(
    'account-delete-email',
    {
      userId: user.id,
      email: user.email,
      name: user.name,
    },
    {
      attempts: 3,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: 100,
      removeOnFail: 50,
    }
  );
  console.log(`Queued account deletion email for ${user.email}`);
}
Enter fullscreen mode Exit fullscreen mode

What's happening here?

new Queue('emails', { connection }) — creates a queue called "emails" backed by Redis. If the queue doesn't exist, BullMQ creates it automatically.

emailQueue.add(name, data, options) — drops a job into the queue. Returns immediately — this is the non-blocking part. Your API doesn't wait for the email to send.

attempts: 3 — if the job fails (email service is down, network error etc), BullMQ retries automatically up to 3 times.

backoff: exponential — waits 2s before retry 1, 4s before retry 2, 8s before retry 3. Gives the failing service time to recover instead of hammering it.


The Worker — Your Postman

import { Worker } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  maxRetriesPerRequest: null,
});

const worker = new Worker(
  'emails',              // which queue to watch
  async (job) => {       // function to run for each job
    console.log(`Processing job: ${job.name}`, job.data);

    if (job.name === 'welcome-email') {
      const { email, name } = job.data;
      await sendWelcomeEmail(email, name);
    } else if (job.name === 'account-delete-email') {
      const { email, name } = job.data;
      await sendAccountDeleteEmail(email, name);
    }
  },
  {
    connection,
    concurrency: 5, // process up to 5 jobs simultaneously
  }
);

// Simulate sending a welcome email
async function sendWelcomeEmail(email, name) {
  await new Promise(resolve => setTimeout(resolve, 1000)); // simulate delay
  console.log(`  Welcome email sent to ${name} at ${email}`);
}

// Simulate sending a deletion email
async function sendAccountDeleteEmail(email, name) {
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log(`  Account deletion email sent to ${name} at ${email}`);
}

// Event listeners — know what's happening
worker.on('completed', (job) => {
  console.log(` Job ${job.id} (${job.name}) completed`);
});

worker.on('failed', (job, err) => {
  console.error(` Job ${job.id} (${job.name}) failed:`, err.message);
});

worker.on('active', (job) => {
  console.log(`  Job ${job.id} (${job.name}) is now active`);
});

console.log('Email worker started, waiting for jobs...');
Enter fullscreen mode Exit fullscreen mode

What's happening here?

new Worker('emails', handler, options) — watches the "emails" queue. Every time a job arrives, it calls the handler function with that job.

concurrency: 5 — process up to 5 jobs simultaneously. Without this, jobs are processed one at a time. With 100 emails queued, concurrency of 5 means they're processed in batches of 5 — 5x faster.

job.name and job.data — the name and data you passed when adding the job. The worker uses the name to decide what to do.


Wire it into your API

In your signup route — call queueWelcomeEmail after saving the user. Notice: no await. Fire and forget. The user gets their response immediately.

import { queueWelcomeEmail } from '../queues/emailQueue.js';

router.post('/signup', async (req, res) => {
  // Save user to database
  const user = await createUser(req.body);

  // Queue the email — non-blocking, returns immediately
  queueWelcomeEmail(user);

  // User gets their response in ~50ms
  res.status(201).json({ user, accessToken, refreshToken });
});
Enter fullscreen mode Exit fullscreen mode

In your delete user route:

import { queueAccountDeleteEmail } from '../queues/emailQueue.js';

router.delete('/:id', authenticate, authorize('admin'), async (req, res) => {
  const result = await pool.query(
    'DELETE FROM users WHERE id = $1 RETURNING *', [id]
  );

  // Queue deletion email — non-blocking
  queueAccountDeleteEmail(result.rows[0]);

  res.json({ message: 'User deleted' });
});
Enter fullscreen mode Exit fullscreen mode

Run it

# Terminal 1 — your API server
node index.js

# Terminal 2 — your email worker (separate process)
node src/queues/emailWorker.js

# Terminal 3 — trigger a signup
curl -X POST http://localhost:3000/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"name":"Prometheus","email":"p@test.com","password":"secret123"}'
Enter fullscreen mode Exit fullscreen mode

Terminal 1 (API):

Queued welcome email for p@test.com   ← instant
Enter fullscreen mode Exit fullscreen mode

Terminal 2 (Worker):

⚙️  Job 1 (welcome-email) is now active
Processing job: welcome-email { userId: 1, email: 'p@test.com', name: 'Prometheus' }
-  Welcome email sent to Prometheus at p@test.com
- Job 1 (welcome-email) completed
Enter fullscreen mode Exit fullscreen mode

API responded instantly. Email sent in the background. User is happy.


What About Failures?

This is where BullMQ really shines. Suppose SendGrid goes down mid-request:

Job fires → SendGrid returns 503 → BullMQ catches the error
Wait 2 seconds → retry 1 → SendGrid still down
Wait 4 seconds → retry 2 → SendGrid recovered
 Email sent on attempt 3
Enter fullscreen mode Exit fullscreen mode

BullMQ handles all of this automatically. You just configure attempts and backoff — the retry logic is built in.

If all 3 attempts fail, the job moves to the failed queue in Redis. You can inspect it, fix the issue, and re-queue it manually. Nothing is lost.


Inspect Your Queues in Redis

redis-cli

# See all BullMQ keys
KEYS bull:*

# See completed jobs
ZRANGE bull:emails:completed 0 -1

# Inspect a specific job
HGETALL bull:emails:1
Enter fullscreen mode Exit fullscreen mode

You'll see every field — job name, data, attempts made, timestamps, status. In production you'd build a dashboard on top of this (BullMQ has an official one called Bull Board).


The Takeaway

Synchronous:  User waits 7 seconds for email + slack + analytics
Async queues: User waits 50ms. Everything else happens in background.
Enter fullscreen mode Exit fullscreen mode

The rule — if a task doesn't need to finish before you respond to the user, queue it.

Welcome emails, push notifications, image resizing, PDF generation, analytics events, Slack messages — all of these belong in a queue.

Your API should do one thing: save the data and respond. Everything else is someone else's job.


Still documenting my backend journey publicly — next up is Kafka event streaming, Docker, and system design at scale.

X: [your handle]

GitHub: [your link]

Top comments (0)