+39 3662317539/+34 620899163
info@simonecosci.com

AbortController: annullare una fetch senza lasciare niente appeso

Sviluppo Siti Web a Tenerife

AbortController: annullare una fetch senza lasciare niente appeso

Campo di ricerca che interroga l’API a ogni tasto. L’utente scrive ten, poi tene, poi
tenerife. Sono partite tre richieste e tornano quando vogliono: se la risposta di ten
arriva per ultima, in pagina finiscono i risultati sbagliati mentre nel campo c’è scritto
tutt’altro. Non è un bug che si vede in sviluppo con l’API in locale che risponde in 4 ms.
Si vede in produzione, sul telefono di un cliente, sotto rete lenta.

La soluzione non è mettere un setTimeout di mezzo secondo e sperare. È annullare le
richieste che non servono più.

Il controller, il segnale, l’abort

AbortController è un oggetto con due cose: un signal da passare a chi fa il lavoro,
e un metodo abort() per dire basta. Chi riceve il segnale si ferma.

let controller

async function cerca(q) {
  controller?.abort()                 // la ricerca precedente non serve più
  controller = new AbortController()

  try {
    const r = await fetch(`/api/cerca?q=${encodeURIComponent(q)}`, {
      signal: controller.signal,
    })
    mostra(await r.json())
  } catch (err) {
    if (err.name === 'AbortError') return   // l'abbiamo annullata noi, va bene così
    throw err
  }
}

Tre righe di differenza rispetto a una fetch normale. Quando si annulla, la promise di
fetch non si risolve: rigetta con una DOMException il cui name è AbortError.
Va intercettata, altrimenti in console compare un errore a ogni tasto premuto e ci si
convince che qualcosa non funzioni.

La parte che quasi nessuno usa: i listener

addEventListener accetta la stessa signal. Quando il controller viene abortito,
il listener si stacca da solo.

const ac = new AbortController()
const { signal } = ac

window.addEventListener('resize', ridisegna, { signal })
window.addEventListener('scroll', aggiorna, { signal })
document.addEventListener('keydown', chiudiConEsc, { signal })
elemento.addEventListener('click', apri, { signal })

// smontaggio del componente: una riga, e sono tutti via
ac.abort()

Il confronto è con la versione senza segnale: quattro removeEventListener da tenere
allineati a mano, ognuno con lo stesso identico riferimento alla funzione, altrimenti
non rimuove niente e nessuno se ne accorge. È il classico leak che non fa rumore: la
pagina rallenta dopo mezz’ora d’uso e non si capisce perché.

Lo stesso controller può gestire insieme le fetch in volo e i listener attaccati. Un
abort() solo, e il componente è pulito davvero.

Timeout, senza scriverselo

Per una richiesta che deve morire dopo N millisecondi non serve un controller a mano:

const r = await fetch('/api/report', { signal: AbortSignal.timeout(8000) })

Attenzione al dettaglio: allo scadere del tempo l’errore ha name uguale a
TimeoutError, non AbortError. È voluto, e serve, perché sono due casi diversi: uno
è “l’utente ha cambiato idea”, l’altro è “il server non risponde” e forse va segnalato.

Per averli entrambi — annullabile a mano e con un tetto di tempo — si combinano:

const ac = new AbortController()
const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(8000)])

const r = await fetch('/api/report', { signal })

AbortSignal.any() è più recente degli altri (browser del 2024 in avanti, Node 20.3+):
se il progetto deve girare su qualcosa di più vecchio, va verificato prima.

Anche nel proprio codice

Il segnale non è roba solo di fetch. In un ciclo lungo che elabora dati lato client:

function elaboraTutto(righe, signal) {
  for (const riga of righe) {
    signal.throwIfAborted()     // esce con l'errore giusto se nel frattempo si è abortito
    trasforma(riga)
  }
}

throwIfAborted() fa esattamente quello che dice. In alternativa c’è signal.aborted
(booleano) e signal.addEventListener('abort', ...) per chiudere una connessione, un
worker o un EventSource.

Cosa evitare

Non riusare un controller. Una volta abortito, resta abortito per sempre: se lo si
ricicla, la fetch successiva parte già annullata e la funzione sembra rotta a caso.
Un controller per operazione, come nell’esempio della ricerca.

Non credere che abort() fermi il server. Il browser chiude la connessione, ma la
richiesta è già arrivata: la query gira lo stesso, e se era una POST che scrive, il
dato viene scritto. abort() risolve il problema del client (risposta obsoleta,
connessione occupata), non quello del carico sul backend. Per fermare davvero il lavoro
lato server serve una logica lato server.

Non ingoiare tutto nel catch. Un catch {} vuoto nasconde l’AbortError insieme
al 500 e al DNS che non risolve. Si controlla err.name e si rilancia il resto.

Non sostituirlo con un flag. Il trucco di segnare annullata = true e ignorare la
risposta quando arriva evita il risultato sbagliato in pagina, ma la richiesta resta in
volo: occupa una delle connessioni parallele che il browser concede al dominio e rallenta
quella buona. Con l’abort la connessione si libera subito.

Il supporto è ovunque da anni — AbortController con fetch in tutti i browser
moderni e in Node, la signal su addEventListener dal 2021, AbortSignal.timeout()
dal 2022. Non c’è un vero motivo per non usarlo.