Como aplicar um timeout ou cancelar uma requisição com o fetch do JavaScript

Cansado de requisições HTTP lentas e travamentos? Aprenda a usar AbortSignal e AbortController no JavaScript para configurar timeouts e cancelar requisições com eficiência, otimizando a performance e a experiência do usuário no seu site. Descubra como implementar essa técnica neste post!

Como aplicar um timeout ou cancelar uma requisição com o fetch do JavaScript
Como aplicar um timeout ou cancelar uma requisição com o fetch do JavaScript

Aplicar um timeout em uma requisição é útil para evitar que uma aplicação fique presa esperando por uma resposta que pode não chegar, seja por problemas na rede ou no próprio servidor.

Além disso, outro recurso importante para ampliar o controle sobre as requisições de uma aplicação é a capacidade de cancelamento, que pode ser iniciada pelo usuário ou por regras de negócio. Ao cancelar uma requisição, interrompemos a transmissão de dados, reduzindo o tráfego e aliviando a carga de processamento.

Neste texto, vamos explorar como aplicar timeouts em requisições utilizando a função fetch do JavaScript e como cancelar uma requisição ainda em processamento.

Timeout com Promise.race

Antes da introdução de mecanismos de cancelamento nativos no fetch, era comum usar o Promise.race para criar o mecanismo de timeout nas requisições. A ideia era a seguinte: o fetch retorna uma promise, que representa uma promessa de valor futuro. Em paralelo, criamos outra promise que lança um erro após um determinado período de tempo. Ao usar o Promise.race, colocamos as duas promises para "competir". A primeira promise a ser resolvida, seja com sucesso ou com erro, é a que será utilizada.

function request(url, timeoutEmMS = 3000) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Timeout')), timeoutEmMS)
    )
  ]);
}

É importante notar que o Promise.race não cancela a requisição fetch em si, apenas ignora a resposta caso o tempo limite seja excedido. A seguir vamos abordar os métodos mais modernos de cancelamento, como o AbortController.

Definindo o timeout de uma requisição com o fetch e AbortSignal

Configurar o timeout utilizando o AbortSignal é extremamente simples, basta utilizar o AbortSignal.timeout(valor em milissegundos):

async function request(url, timeoutEmMS = 3000) {
  return fetch(url, {
    signal: AbortSignal.timeout(timeoutEmMS),
  });
}
The AbortSignal interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortControllerobject.

Fonte: link

Como cancelar uma requisição em andamento utilizando fetch e AbortController

Um outro mecanismo para cancelar uma requisição em andamento é através do AbortController:

async function request(url, timeoutEmMS = 3000) {
  const controller = new AbortController();

  // Aqui normalmente adicionamos algum
  // eventListener para chamar o método
  // controller.abort(). Assim a requisição
  // é cancelada
  
  return fetch(url, {
    signal: controller.signal
  });
}
The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.
You can create a new AbortController object using the AbortController() constructor. Communicating with an asynchronous operation is done using an AbortSignal object.

Fonte: link

Testando o timeout e cancelamento juntos

Para testar estes dois mecanismos, o timeout e o cancelamento, criei um pequeno cenário de estudo. O código abaixo, executado com Node.js na versão 20.12.0, tem dois endpoints:

  1. localhost:3000/home
    retorna o conteúdo do arquivo index.html (logo abaixo)
  2. localhost:3000/
    retorna um JSON com um delay de 4 segundos. Este atraso é proposital para facilitar os testes
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';

const server = createServer((req, res) => {
  const path = req.url;

  if (path === '/home') {
    res.writeHead(200, { 'Content-Type': 'text/html' });

    const stream = createReadStream('./index.html');
    stream.pipe(res);
    return;
  }

  // * *********************************************
  // O conteúdo abaixo é entregue com um atraso
  // de 4 segundos. Assim ficará mais fácil para
  // testar o mecanismo de timeout e cancelamento
  // * *********************************************
  const delaySeconds = 4;
  setTimeout(() => {
    res.writeHead(200, {
      'Content-Type': 'application/json',
    });

    res.end(JSON.stringify({ mensagem: 'Olá' }));
  }, delaySeconds * 1_000);
});

server.listen(3000, '127.0.0.1', () => {
  console.log('Listening on 127.0.0.1:3000');
});

server.js

<!DOCTYPE html>
<html lang="en">
 <head>
  <meta charset="UTF-8" />
  <meta
   name="viewport"
   content="width=device-width, initial-scale=1.0" />
  <title>Teste</title>
 </head>
 <body>
  <p>Página de testes.</p>
  <div>
   <label for="timeoutMS">Timeout (s): </label>
   <input
    type="number"
    id="timeoutMS"
    value="2" />
  </div>
  <div>
   <button id="botaoRequisitar">Disparar Requisição</button>
  </div>
  <div>
   <p>Resultado:</p>
   <code id="resultado"></code>
  </div>
  <script>
   (() => {
    const resultadoElement =
     document.querySelector('#resultado');
    const timeoutElement =
     document.querySelector('#timeoutMS');
    const botaoRequisitar = document.querySelector(
     '#botaoRequisitar',
    );

    function mostrarMensagem(mensagem) {
     resultadoElement.textContent = mensagem;
    }

    function habilitarBotaoDispararRequisicao() {
     botaoRequisitar.removeAttribute('disabled');
    }

    function desabilitarBotaoDispararRequisicao() {
     botaoRequisitar.setAttribute('disabled', 'disabled');
    }

    function criarBotaoCancelamento() {
     const botao = document.createElement('button');
     botao.textContent = 'Cancelar';
     return botao;
    }

    botaoRequisitar.addEventListener('click', async () => {
     mostrarMensagem('processando...');
     desabilitarBotaoDispararRequisicao();

     const controller = new AbortController();
     const botaoCancelar = criarBotaoCancelamento();
     botaoCancelar.addEventListener('click', () => {
      controller.abort();
     });

     botaoRequisitar.parentElement.appendChild(
      botaoCancelar,
     );

     const timeoutMS =
      parseInt(timeoutElement.value, 10) * 1_000;

     let resultado = '';
     const url = 'http://localhost:3000';
     try {
      const resposta = await fetch(url, {
       signal: AbortSignal.any([
        controller.signal,
        AbortSignal.timeout(timeoutMS),
       ]),
      });

      resultado = JSON.stringify(await resposta.json());
     } catch (error) {
      resultado = `Name: ${error.name}; Code: ${error.code}; Message: ${error.message}`;
     } finally {
      habilitarBotaoDispararRequisicao();
      botaoRequisitar.parentElement.removeChild(
       botaoCancelar,
      );
     }

     mostrarMensagem(resultado);
    });
   })();
  </script>
 </body>
</html>

index.html

Para executar a aplicação utilizei o seguinte comando:

node --experimental-default-type module server.js

Após executar o projeto, basta abrir o endereço localhost:3000/home no navegador e executar os testes, conforme a imagem abaixo ilustra:

Navegador executando o código acima. Primeiro ocorre um timeout na requisição, depois um cancelamento voluntário e por fim aumentamos o timeout para que a requisição seja concluída
Resultado dos testes

Tanto o timeout quanto o cancelamento voluntário funcionaram perfeitamente, interrompendo a requisição em execução. Também é interessante notar que, a mensagem de erro varia conforme o navegador, mas mantém o mesmo significado. Abaixo está o resultado da exceção para Timeout ou cancelamento da requisição em 3 navegadores, detalhando os campos name, code e message do objeto de erro.

Safari (17.4.1)

  • Timeout:
    • error.name: AbortError
    • error.code: 20
    • error.message: Fetch is aborted
  • Abort:
    • error.name: AbortError
    • error.code: 20
    • error.message: Fetch is aborted


Chrome (124.0.6367.79 (Official Build) (arm64))

  • Timeout:
    • error.name: TimeoutError
    • error.code: 23
    • error.message: signal timed out
  • Abort:
    • error.name: AbortError
    • error.code: 20
    • error.message: signal is aborted without reason

Firefox Developer Edition (125.0b3 (64-bit))

  • Timeout:
    • error.name: TimeoutError
    • error.code: 23
    • error.message: The operation timed out
  • Abort:
    • error.name: AbortError
    • error.code: 20
    • error.message: The operation was aborted

Considerações finais

Aplicar timeout em requisições HTTP é uma prática recomendada. Ao definir limites de tempo para esperar por respostas do servidor, podemos evitar que nossa aplicação fique presa aguardando indefinidamente e proporcionar uma experiência mais fluida para os usuários.

Falando rapidamente do suporte, a maioria dos navegadores modernos já dá suporte ao AbortSignal/AbortController. Para mais detalhes, consulte este link. Já para Node.js, o AbortController está disponível de forma definitiva a partir da versão 15.4.0 conforme este link.