Zuverlässig eine HTTP-Anfrage senden, wenn ein Benutzer eine Seite verlässt

Avatar of Alex MacArthur
Alex MacArthur am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Bei mehreren Gelegenheiten musste ich eine `HTTP`-Anfrage mit einigen Daten senden, um zu protokollieren, wenn ein Benutzer etwas tut, wie z. B. zu einer anderen Seite navigiert oder ein Formular absendet. Betrachten Sie dieses konstruierte Beispiel, um einige Informationen an einen externen Dienst zu senden, wenn auf einen Link geklickt wird.

<a href="/some-other-page" id="link">Go to Page</a>

<script>
document.getElementById('link').addEventListener('click', (e) => {
  fetch("/log", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    }, 
    body: JSON.stringify({
      some: "data"
    })
  });
});
</script>

Hier passiert nichts besonders Kompliziertes. Der Link darf sich wie gewohnt verhalten (ich verwende kein `e.preventDefault()`), aber bevor dieses Verhalten eintritt, wird bei einem `click` eine `POST`-Anfrage ausgelöst. Es besteht keine Notwendigkeit, auf eine Antwort zu warten. **Ich möchte nur, dass sie gesendet wird** an welchen Dienst auch immer ich anpinge.

Auf den ersten Blick würden Sie vielleicht erwarten, dass die Zustellung dieser Anfrage synchron erfolgt, danach navigieren wir weiter von der Seite weg, während ein anderer Server diese Anfrage erfolgreich bearbeitet. Aber wie sich herausstellt, ist das nicht immer der Fall.

Browser garantieren nicht, offene HTTP-Anfragen zu erhalten

Wenn etwas passiert, um eine Seite im Browser zu beenden, gibt es keine Garantie, dass eine laufende `HTTP`-Anfrage erfolgreich sein wird (siehe mehr über die "beendeten" und andere Zustände des Seitenlebenszyklus unter hier). Die Zuverlässigkeit dieser Anfragen kann von mehreren Faktoren abhängen – Netzwerkverbindung, Anwendungsleistung und sogar die Konfiguration des externen Dienstes selbst.

Daher kann das Senden von Daten in diesen Momenten alles andere als zuverlässig sein, was ein potenziell erhebliches Problem darstellt, wenn Sie sich auf diese Protokolle verlassen, um datenintensive Geschäftsentscheidungen zu treffen.

Um diese Unzuverlässigkeit zu veranschaulichen, habe ich eine kleine Express-Anwendung mit einer Seite eingerichtet, die den oben genannten Code verwendet. Wenn auf den Link geklickt wird, navigiert der Browser zu `/other`, aber bevor dies geschieht, wird eine `POST`-Anfrage ausgelöst.

Während alles geschieht, habe ich den Netzwerk-Tab des Browsers geöffnet und verwende eine "Langsame 3G"-Geschwindigkeit. Sobald die Seite geladen ist und ich das Protokoll gelöscht habe, sieht es ziemlich ruhig aus.

Viewing HTTP request in the network tab

Aber sobald auf den Link geklickt wird, geht alles schief. Wenn Navigation stattfindet, wird die Anfrage abgebrochen.

Viewing HTTP request fail in the network tab

Und das lässt uns wenig Vertrauen darin haben, dass der externe Dienst die Anfrage tatsächlich verarbeiten konnte. Nur um dieses Verhalten zu überprüfen, tritt es auch auf, wenn wir programmatisch mit `window.location` navigieren.

document.getElementById('link').addEventListener('click', (e) => {
+ e.preventDefault();

  // Request is queued, but cancelled as soon as navigation occurs. 
  fetch("/log", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    }, 
    body: JSON.stringify({
      some: 'data'
    }),
  });

+ window.location = e.target.href;
});

Unabhängig davon, wie oder wann die Navigation stattfindet und die aktive Seite beendet wird, sind diese unvollständigen Anfragen dem Risiko ausgesetzt, abgebrochen zu werden.

Aber warum werden sie abgebrochen?

Die Wurzel des Problems ist, dass XHR-Anfragen (über `fetch` oder `XMLHttpRequest`) standardmäßig asynchron und nicht blockierend sind. Sobald die Anfrage in die Warteschlange gestellt wird, wird die eigentliche *Arbeit* der Anfrage im Hintergrund an eine Browser-API übergeben.

In Bezug auf die Leistung ist das gut – Sie möchten nicht, dass Anfragen den Hauptthread blockieren. Es besteht aber auch die Gefahr, dass sie verworfen werden, wenn eine Seite in diesen "beendeten" Zustand übergeht, was keine Garantie dafür bietet, dass die im Hintergrund laufende Arbeit abgeschlossen wird. Hier ist, wie Google diesen spezifischen Lebenszyklusstatus zusammenfasst.

Eine Seite befindet sich im beendeten Zustand, sobald sie mit dem Entladen begonnen hat und vom Browser aus dem Speicher gelöscht wurde. In diesem Zustand können keine neuen Aufgaben gestartet werden, und laufende Aufgaben können beendet werden, wenn sie zu lange dauern.

Kurz gesagt, der Browser ist mit der Annahme konzipiert, dass beim Schließen einer Seite keine Notwendigkeit besteht, Hintergrundprozesse fortzusetzen, die von ihr in die Warteschlange gestellt wurden.

Was sind also unsere Optionen?

Vielleicht ist der offensichtlichste Ansatz, dieses Problem zu vermeiden, die Benutzeraktion so weit wie möglich zu verzögern, bis die Anfrage eine Antwort zurückgibt. In der Vergangenheit wurde dies auf die falsche Weise durch die Verwendung des synchronen Flags, das in `XMLHttpRequest` unterstützt wird, erreicht. Aber dessen Verwendung blockiert den Hauptthread vollständig und verursacht eine Reihe von Leistungsproblemen – ich habe in der Vergangenheit über einige davon geschrieben –, daher sollte die Idee überhaupt nicht in Betracht gezogen werden. Tatsächlich ist sie bereits auf dem Weg aus der Plattform (Chrome v80+ hat sie bereits entfernt).

Stattdessen ist es besser, auf die Auflösung eines `Promise` zu warten, wenn eine Antwort zurückgegeben wird. Danach können Sie das Verhalten sicher ausführen. Mit unserem früheren Snippet könnte das etwa so aussehen.

document.getElementById('link').addEventListener('click', async (e) => {
  e.preventDefault();

  // Wait for response to come back...
  await fetch("/log", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    }, 
    body: JSON.stringify({
      some: 'data'
    }),
  });

  // ...and THEN navigate away.
   window.location = e.target.href;
});

Das erledigt die Aufgabe, aber es gibt einige nicht unerhebliche Nachteile.

**Erstens beeinträchtigt es die Benutzererfahrung, indem es das gewünschte Verhalten verzögert.** Das Sammeln von Analysedaten nützt sicherlich dem Unternehmen (und hoffentlich zukünftigen Benutzern), aber es ist weniger als ideal, Ihre *gegenwärtigen* Benutzer die Kosten tragen zu lassen, um diese Vorteile zu realisieren. Ganz zu schweigen davon, dass bei externen Abhängigkeiten Latenzzeiten oder andere Leistungsprobleme des Dienstes selbst für den Benutzer sichtbar werden. Wenn Timeouts von Ihrem Analysedienst dazu führen, dass ein Kunde eine hochwertige Aktion nicht abschließen kann, verliert jeder.

**Zweitens ist dieser Ansatz nicht so zuverlässig, wie er zunächst klingt, da einige Abbruchverhaltensweisen nicht programmatisch verzögert werden können.** Zum Beispiel ist `e.preventDefault()` nutzlos, um jemanden vom Schließen eines Browser-Tabs abzuhalten. Daher wird er im besten Fall das Sammeln von Daten für *einige* Benutzeraktionen abdecken, aber nicht genug, um ihm umfassend vertrauen zu können.

Dem Browser anweisen, ausstehende Anfragen zu erhalten

Glücklicherweise gibt es Optionen, um ausstehende `HTTP`-Anfragen zu *erhalten*, die in den meisten Browsern integriert sind und keine Beeinträchtigung der Benutzererfahrung erfordern.

Verwendung des `keepalive`-Flags von Fetch

Wenn das `keepalive`-Flag beim Verwenden von `fetch()` auf `true` gesetzt ist, bleibt die entsprechende Anfrage geöffnet, auch wenn die Seite, die sie initiiert hat, beendet wird. Unter Verwendung unseres ursprünglichen Beispiels wäre dies eine Implementierung, die so aussieht.

<a href="/some-other-page" id="link">Go to Page</a>

<script>
  document.getElementById('link').addEventListener('click', (e) => {
    fetch("/log", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      }, 
      body: JSON.stringify({
        some: "data"
      }), 
      keepalive: true
    });
  });
</script>

Wenn auf diesen Link geklickt wird und die Seitenavigation erfolgt, findet keine Anfrageabbruch statt.

Viewing HTTP request succeed in the network tab

Stattdessen bleiben wir mit einem `(unknown)`-Status zurück, einfach weil die aktive Seite nie darauf gewartet hat, eine Antwort zu erhalten.

Eine Einzeiler wie diese ist eine einfache Lösung, besonders wenn sie Teil einer gängigen Browser-API ist. Aber wenn Sie nach einer fokussierteren Option mit einer einfacheren Oberfläche suchen, gibt es einen anderen Weg mit praktisch derselben Browserunterstützung.

Verwendung von `Navigator.sendBeacon()`

Die Funktion `Navigator.sendBeacon()` ist speziell für das Senden von Einweganfragen (Beacons) vorgesehen (Beacons). Eine einfache Implementierung sieht so aus, wobei ein `POST` mit stringifiziertem JSON und einem "text/plain" `Content-Type` gesendet wird.

navigator.sendBeacon('/log', JSON.stringify({
  some: "data"
}));

Aber diese API erlaubt Ihnen nicht, benutzerdefinierte Header zu senden. Um also unsere Daten als "application/json" zu senden, müssen wir eine kleine Anpassung vornehmen und ein `Blob` verwenden.

<a href="/some-other-page" id="link">Go to Page</a>

<script>
  document.getElementById('link').addEventListener('click', (e) => {
    const blob = new Blob([JSON.stringify({ some: "data" })], { type: 'application/json; charset=UTF-8' });
    navigator.sendBeacon('/log', blob));
  });
</script>

Am Ende erhalten wir dasselbe Ergebnis – eine Anfrage, die auch nach der Seitenavigation abgeschlossen werden kann. Aber es gibt noch etwas mehr, das ihr einen Vorteil gegenüber `fetch()` verschaffen könnte: Beacons werden mit geringer Priorität gesendet.

Zur Veranschaulichung, hier ist, was im Netzwerk-Tab angezeigt wird, wenn sowohl `fetch()` mit `keepalive` *als auch* `sendBeacon()` gleichzeitig verwendet werden.

Viewing HTTP request in the network tab

Standardmäßig erhält `fetch()` eine "Hohe" Priorität, während der Beacon (oben als "ping"-Typ bezeichnet) die "Niedrigste" Priorität hat. Für Anfragen, die für die Funktionalität der Seite nicht kritisch sind, ist das eine gute Sache. Direkt aus der Beacon-Spezifikation.

Diese Spezifikation definiert eine Schnittstelle, die [...] die Ressourcenkonkurrenz mit anderen zeitkritischen Operationen minimiert und gleichzeitig sicherstellt, dass solche Anfragen dennoch verarbeitet und an ihr Ziel geliefert werden.

Anders ausgedrückt: `sendBeacon()` stellt sicher, dass seine Anfragen die wirklich wichtigen für Ihre Anwendung und die Benutzererfahrung nicht behindern.

Eine ehrenvolle Erwähnung für das `ping`-Attribut

Es ist erwähnenswert, dass eine wachsende Zahl von Browsern das `ping`-Attribut unterstützt. Wenn es an Links angehängt wird, löst es eine kleine `POST`-Anfrage aus.

<a href="https://:3000/other" ping="https://:3000/log">
  Go to Other Page
</a>

Und diese Anfrage-Header enthalten die Seite, auf der auf den Link geklickt wurde (`ping-from`) sowie den `href`-Wert dieses Links (`ping-to`).

headers: {
  'ping-from': 'https://:3000/',
  'ping-to': 'https://:3000/other'
  'content-type': 'text/ping'
  // ...other headers
},

Es ist technisch ähnlich wie das Senden eines Beacons, hat aber einige bemerkenswerte Einschränkungen.

  1. Es ist streng auf die Verwendung bei Links beschränkt, was es zu einem Nichtstarter macht, wenn Sie Daten verfolgen müssen, die mit anderen Interaktionen wie Button-Klicks oder Formularübermittlungen verbunden sind.
  2. Die Browserunterstützung ist gut, aber nicht großartig. Zum Zeitpunkt der Erstellung dieses Artikels ist Firefox standardmäßig nicht aktiviert.
  3. Sie können keine benutzerdefinierten Daten zusammen mit der Anfrage senden. Wie bereits erwähnt, erhalten Sie höchstens ein paar `ping-*`-Header, zusammen mit allen anderen Headern, die mitgeschickt werden.

Alles in allem ist `ping` ein gutes Werkzeug, wenn Sie damit einverstanden sind, einfache Anfragen zu senden und keinen benutzerdefinierten JavaScript-Code schreiben möchten. Wenn Sie jedoch etwas Substantielleres senden müssen, ist es möglicherweise nicht die beste Wahl.

Welches sollte ich also wählen?

Es gibt definitiv Kompromisse bei der Verwendung von entweder `fetch` mit `keepalive` oder `sendBeacon()` zum Senden Ihrer letzten Anfragen. Um zu entscheiden, welche für verschiedene Umstände am besten geeignet ist, hier einige Überlegungen.

Sie könnten `fetch()` + `keepalive` verwenden, wenn

  • Sie benutzerdefinierte Header einfach mit der Anfrage übergeben müssen.
  • Sie eine `GET`-Anfrage an einen Dienst senden möchten, anstatt einer `POST`-Anfrage.
  • Sie ältere Browser (wie IE) unterstützen und bereits ein `fetch`-Polyfill laden.

Aber `sendBeacon()` könnte eine bessere Wahl sein, wenn

  • Sie einfache Service-Anfragen machen, die nicht viel Anpassung benötigen.
  • Sie die sauberere, elegantere API bevorzugen.
  • Sie sicherstellen möchten, dass Ihre Anfragen nicht mit anderen hoch priorisierten Anfragen in der Anwendung konkurrieren.

Vermeiden Sie es, meine Fehler zu wiederholen

Es gibt einen Grund, warum ich mich entschieden habe, die Natur, wie Browser laufende Anfragen behandeln, wenn eine Seite beendet wird, eingehend zu untersuchen. Vor einiger Zeit sah mein Team eine plötzliche Änderung in der Häufigkeit einer bestimmten Art von Analyseprotokoll, nachdem wir die Anfrage gerade dann ausgelöst hatten, wenn ein Formular abgeschickt wurde. Die Änderung war abrupt und signifikant – ein Rückgang von ~30% gegenüber dem, was wir historisch gesehen hatten.

Die Gründe, warum dieses Problem auftrat, sowie die verfügbaren Werkzeuge, um es erneut zu vermeiden, haben den Tag gerettet. Wenn also irgendetwas, hoffe ich, dass das Verständnis der Nuancen dieser Herausforderungen jemandem hilft, einige der Schwierigkeiten zu vermeiden, auf die wir gestoßen sind. Viel Spaß beim Protokollieren!