DEV Community

TJ Coding
TJ Coding

Posted on

The "Zombie Request" Problem: Why Your Backend Keeps Working After the User Quits

There is a dangerous assumption that 90% of Node.js developers make.

They assume that if a user closes their tab, refreshes the page, or cancels an API request, the backend automatically stops processing that request.

It does not.

In Node.js, the HTTP layer is decoupled from your logic. If a user requests a heavy report and immediately closes the window, your server will dutifully spend the next 30 seconds crunching numbers, heating up the CPU, and running database queries, only to realize at the very last millisecond: "Oh, the socket is dead. I have nowhere to send this."

This is a Zombie Request. In high-throughput systems, these zombies can consume up to 40% of your resources.

Here is how to identify, track, and kill them using AbortController.

The Proof: A Simple Experiment

You don't have to take my word for it. Create a simple Express route that simulates work using a timeout.

app.get('/heavy-work', async (req, res) => {
  console.log('1. Starting Work...');

  // Simulate a 5-second database aggregation
  await new Promise(resolve => setTimeout(resolve, 5000));

  console.log('2. Work Finished!');
  console.log('3. Sending Response...');

  res.send('Done');
});
Enter fullscreen mode Exit fullscreen mode

The Test:

  1. Open /heavy-work in your browser.
  2. Wait 1 second.
  3. Close the tab.

The Result:
Your terminal will print Work Finished! and Sending Response... four seconds after you closed the tab. You just paid AWS for computation that provided zero value.


The Architecture of a Fix

To solve this, we need to propagate a cancellation signal from the Network Layer (Express) down to the Application Layer (Services) and finally to the Infrastructure Layer (Database/External APIs).

We will use the standard AbortController API to standardize this flow.

Step 1: The Abort Middleware

Don't handle this in every single controller. Create a middleware that attaches an AbortSignal to every request.

// middleware/attachSignal.ts
import { Request, Response, NextFunction } from 'express';

export const attachSignal = (req: Request, res: Response, next: NextFunction) => {
  const controller = new AbortController();

  // Attach the signal to the request object so controllers can use it
  req.signal = controller.signal;

  // Listen for the socket closing
  req.on('close', () => {
    if (!res.writableFinished) {
      console.log(`[${req.method} ${req.url}] Request aborted by client.`);
      controller.abort();
    }
  });

  next();
};

// Add type definition
declare module 'express-serve-static-core' {
  interface Request {
    signal: AbortSignal;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: The Controller

Now, your controllers don't need to know how the cancellation happened. They just pass the signal along.

app.get('/report', attachSignal, async (req, res) => {
  try {
    // Pass req.signal to your service layer
    const data = await ReportService.generate(req.query.id, { 
      signal: req.signal 
    });
    res.json(data);
  } catch (err) {
    if (req.signal.aborted) return; // Clean exit
    res.status(500).send('Error');
  }
});
Enter fullscreen mode Exit fullscreen mode

Step 3: The "Kill Switch" (Service Layer)

This is where the magic happens. You must modify your expensive operations to respect the signal.

Scenario A: External API Calls (Axios/Fetch)
If your backend calls OpenAI or another microservice, pass the signal.

async function callExternalAPI(signal: AbortSignal) {
  // If the user cancels, this fetch aborts instantly
  // preventing your server from waiting on a 3rd party
  const response = await fetch('https://api.openai.com/...', { signal });
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Scenario B: Database Queries (Postgres)
Stopping a JavaScript loop is easy. Stopping a running SQL query is hard.
If you use pg directly, you can implement a "Cancellation Token" pattern.

import { Pool } from 'pg';

async function heavyQuery(signal: AbortSignal) {
  const client = await pool.connect();

  try {
    // 1. Get the PID of this connection
    const { rows } = await client.query('SELECT pg_backend_pid()');
    const pid = rows[0].pg_backend_pid;

    // 2. Setup the kill switch
    const abortHandler = () => {
      // Open a NEW connection to kill the OLD one
      pool.query('SELECT pg_cancel_backend($1)', [pid]).catch(console.error);
    };
    signal.addEventListener('abort', abortHandler);

    // 3. Run the heavy query
    const result = await client.query('SELECT ... FROM huge_table');

    // 4. Cleanup listener if query finishes successfully
    signal.removeEventListener('abort', abortHandler);
    return result;
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

The "Critical Side Effect" Warning

Zombie requests aren't just about performance; they are about correctness.

Imagine an endpoint that:

  1. Generates an invoice (3 seconds).
  2. Charges the Credit Card.
  3. Emails the user.

If the user clicks "Buy," notices a typo, and hits "Stop" (or closes the tab) after 1 second, they expect the transaction to be cancelled.

Without AbortController handling, your server will still charge their card 2 seconds later.

By checking if (signal.aborted) before critical stages, you prevent inconsistent states:

async function processOrder(signal: AbortSignal) {
  await generateInvoice();

  if (signal.aborted) throw new Error('Aborted'); // STOP HERE

  await stripe.charges.create(...); // Never reached if user left
}
Enter fullscreen mode Exit fullscreen mode

Summary

Node.js is asynchronous, but it is not telepathic. It does not know the user has left unless you check.

Implementing "Full-Stack Cancellation" requires three changes:

  1. Detect the close event (req.on('close')).
  2. Propagate the signal (AbortSignal).
  3. Terminate the resource (Fetch/SQL).

It is a small architectural change that saves cloud costs and prevents "ghost" data modifications.

Top comments (0)