D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

`abort()` Fired but No `AbortError`? A Proxy-Buffered Compressed Body Never Gets Cancelled

· · 7 min read
`abort()` Fired but No `AbortError`? A Proxy-Buffered Compressed Body Never Gets Cancelled

I had spent years treating AbortController as a safety net. Attach a signal, set a setTimeout that calls abort(), done. Hanging requests get cut, the promise rejects, the waiting code sees an error and reacts. That was the contract, at least in my head.

Then one afternoon an action in an admin panel never finished, and when I opened the console to find the error, there was nothing there. Not the wrong error. Nothing at all.

The symptom: a promise hanging in total silence

Our internal fetch wrapper had fired a request at one endpoint that was genuinely slow. Here is what came back:

So this was not a failed request. It was a successful request whose result never reached the code waiting on it. From JavaScript's point of view, that promise stopped at pending and stayed there forever.

Why the endpoint was slow has a boring answer: I was working against a third party service's sandbox, and the handler made two sequential calls that take a couple of seconds each in production. In the sandbox the whole thing ran close to a minute. The slowness was an artefact of the test environment, not a bug.

But that minute exposed a real bug: my fetch wrapper had no timeout that actually worked.

What I thought was already safe

The wrapper looked roughly like this. I have renamed it, but the shape is exactly what shipped:

async function apiFetch(url, options = {}) {
  const controller = new AbortController();
  // DEFAULT_TIMEOUT: the wrapper's baseline budget, the number is not the point here
  const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
 
  try {
    const res = await fetch(url, { ...options, signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

On paper this is correct. The timer fires, abort() is called, fetch rejects with an AbortError, finally cleans up. I have written this pattern dozens of times.

That afternoon it simply did not work. The timer fired, abort() genuinely ran, and the promise on that line still never settled.

The root cause: there are two phases inside a single fetch

The part that took me longest to accept: AbortController has no authority over the whole lifecycle of a request, because a single fetch is really two separate steps.

  1. The header phase. The browser opens a connection, sends the request, and waits for a status line and response headers. The moment the headers land, the promise from fetch() resolves. An abort signal cuts this phase cleanly.
  2. The body phase. What you hold after await fetch() is not the response content, it is a promise of the content. Only at res.json() or res.text() does the browser start pulling bytes off a connection that is still open.

That split is what I had been glossing over for years. await fetch(...) can already have completed successfully while the request, in any meaningful sense, has not finished at all.

The site I was working on is served through LiteSpeed with brotli compression enabled. What I observed was that the response from that slow endpoint did not trickle in. The proxy held it until it was complete, compressed it, and only then released it as a single payload. For as long as it sat on that buffer, nothing moved on the browser side. No bytes arriving, no error, no closed connection. Just silence.

Why aborting does not help in the second phase

Two things are happening at once here, and they both lead to the same place.

The first is about what I was waiting on. Once the headers landed, the promise from fetch() had already settled successfully. There was nothing left on that line to reject. My wait had moved to res.json(), which is a different object with a lifecycle of its own.

The second is about what was actually at the far end of the connection. The spec says aborting midway through a body read should error the stream. In my case it did not. A body that streams has chunks that can be torn when the connection drops, and that tear is what usually surfaces as an error. A body still parked in a proxy buffer has no chunks on the browser side at all. There is nothing to tear, because not one byte has arrived. All that is left is a connection idling while the proxy finishes its work, and that is not something my signal can convert into a rejection.

The result: nothing rejected, nothing resolved, the promise stayed pending.

That is the first lesson, and I wrote it down in large letters for myself: AbortController is a request, not a guarantee. It asks for the operation to be cancelled. The network layer decides whether to honour the ask, and the network layer is not always under your code's control. When a proxy in front of you is sitting on a compressed body, your ask may never get answered.

If you need a deadline that is certain to expire, it has to live in JavaScript, independent of the request itself.

The fix: the deadline has to cover both phases

The pattern itself is not exotic. Race the request against a plain JavaScript timer so somebody always wins, whatever the network layer decides to do. What was new to me here was not the pattern but where the race is drawn. My first attempt still leaked:

// Still leaky: the race stops at the header phase.
const res = await Promise.race([
  fetch(url, { ...options, signal: controller.signal }),
  timeoutReject(limit),
]);
return res.json(); // the hang lives here, outside the race

This race wins comfortably against a connection that never opens, and is completely useless against the bug I actually had. The hang was in the second phase, while res.json() stood outside the race and was free to wait forever.

The correct version treats headers and body as one unit of work, and races that unit:

function timeoutReject(ms) {
  return new Promise((_, reject) => {
    setTimeout(() => {
      const err = new Error("Request timed out");
      // This flag lets callers tell "the network was slow" apart from
      // "the server said no", two cases that deserve different reactions.
      err.timeout = true;
      reject(err);
    }, ms);
  });
}
 
async function apiFetch(url, options = {}) {
  const controller = new AbortController();
  const limit = options.timeout ?? DEFAULT_TIMEOUT;
  const timer = setTimeout(() => controller.abort(), limit);
 
  // One unit of work: from headers all the way through reading the body.
  const request = (async () => {
    const res = await fetch(url, { ...options, signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })();
 
  try {
    return await Promise.race([request, timeoutReject(limit)]);
  } finally {
    clearTimeout(timer);
  }
}

I kept the AbortController in place. When it can release the connection, it does, and that saves real resources. It just stopped being the only thing I rely on to end the wait.

Since then nothing has hung silently. There is always an answer, either from the server or from my own timer. What you should do after a timeout, especially for actions that move money, is a separate story I have written up elsewhere.

What I took away