Irgendetwas ruft JSON.parse auf, direkt oder über res.json(), und die Konsole sagt eines von beidem:

SyntaxError: Unexpected end of JSON input
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Fast alle lesen das als Beschwerde über ihr JSON und gehen ihr JSON ansehen. Das JSON ist fast nie das Problem. Meist gibt es überhaupt kein JSON, und der Parser berichtet Ihnen vom ersten Ding, das er nicht verwenden konnte.

Das sind die Meldungen, die Chrome ausgibt. Firefox und Safari formulieren ihre anders; die Ursachen unten sind dieselben, aus welchem Browser die Meldung auch kam.

Was Ihnen jede Meldung tatsächlich sagt

In einer Browser-Konsole durchprobiert, kommt das heraus:

JSON.parse("")                       // Unexpected end of JSON input
JSON.parse("   ")                    // Unexpected end of JSON input
JSON.parse("undefined")              // "undefined" is not valid JSON
JSON.parse("<html>")                 // Unexpected token '<', "<html>" is not valid JSON
JSON.parse('{"a":1,')                // Expected double-quoted property name in JSON
                                     //   at position 7 (line 1 column 8)

In dieser Liste steckt eine Diagnoseregel, die nirgends ausgesprochen wird, und sie ist das Nützlichste auf dieser Seite:

  • Eine Meldung mit einer Positionsangabe heißt, dass Sie wirklich JSON haben und es früh endet oder unterwegs ungültig wird. Irgendetwas hat eine echte Nutzlast abgeschnitten.
  • Eine Meldung, die einen Ausschnitt zitiert und mit “is not valid JSON” endet, heißt, dass es nie JSON war. Man hat Ihnen etwas völlig anderes gegeben.
  • “Unexpected end of JSON input” heißt, dass der Parser das Ende erreicht hat, bevor er einen vollständigen Wert hatte, was in der Praxis einen leeren Body bedeutet. Leerzeichen zählen als leer.

Das sind drei verschiedene Fehler mit drei verschiedenen Lösungen, und der Fehlertext ist das Einzige, was sie unterscheidet.

Ein leerer Body, und die vier Wege dorthin

“Unexpected end of JSON input” ist das, was JSON.parse("") erzeugt, also lautet die Frage, warum der Body leer war.

Eine Antwort mit 204 No Content ist die ehrliche Variante: Der Server sagt Ihnen, dass da nichts ist, und .json() darauf aufzurufen ist der Fehler. Eine 200 mit leerem Body ist dieselbe Lage ohne die Warnung. Ein bereits gelesener Body tut es ebenso: Response-Bodies sind Streams und lassen sich einmal lesen, also scheitert await res.text() gefolgt von await res.json() beim zweiten Aufruf mit genau dieser Meldung. Und eine abgebrochene Anfrage kann Ihnen je nach Abbruchstelle einen teilweisen oder leeren Body hinterlassen.

Eine Webseite, und das ist der häufige Fall

Die andere Meldung ist die, der Sie wirklich begegnen werden, und sie heißt, dass Ihre Anfrage HTML zurückgegeben hat.

So häufig ist sie wegen einer Eigenheit von fetch, über die jeder einmal stolpert: fetch lehnt bei 4xx oder 5xx nicht ab. Eine 404 ist für das Promise ein vollkommen erfolgreiches Fetch. Wenn also die URL falsch ist, oder die Sitzung ablief und ein Proxy mit einer Anmeldeseite antwortete, oder eine Web Application Firewall eine Prüfung ausliefert, oder der Server eine 500 geworfen hat und Ihr Framework seine eigene Fehlerseite gerendert hat, bekommt res.json() ein Dokument, das mit <!DOCTYPE html> beginnt, und berichtet das <.

Eine Anmeldeseite ist davon die unangenehmste, weil sie meist als 200 ankommt. Am Statuscode ist nichts falsch. Der Body ist bloß ein Anmeldeformular.

Die Verbesserung, die ein Zeichen vor dem Nutzen aufhört

Diese Meldungen waren früher schlechter. V8s Issue 6551, “JSON.parse errors can be user-friendly for short strings”, wurde 2017 eröffnet und im Mai 2022 umgesetzt: davor Unexpected token N in JSON at position 0, danach "NaN" is not valid JSON. Die neue Form zitiert, was sie bekommen hat, was eine echte Verbesserung ist und der Grund dafür, dass die Wendung “in JSON at position 0” aus den Browsern weitgehend verschwunden ist und in jedem Suchergebnis darüber stehen bleibt.

Für dieses Zitat gibt es eine Längengrenze, und sie zu messen lohnt eine Minute, weil sie nirgends dokumentiert ist. Parsen Sie eine Zeichenfolge von zwanzig Zeichen oder weniger, und die Meldung zitiert das Ganze. Ab einundzwanzig zitiert sie die ersten zehn Zeichen und hängt drei Punkte an.

Zählen Sie jetzt die Zeichen in <!DOCTYPE . Es sind genau zehn.

Für die mit Abstand häufigste Ursache dieses Fehlers lautet der hilfreiche neue Ausschnitt also jedes Mal "<!DOCTYPE ".... Eine 404-Seite, eine 500-Seite, eine Anmeldeumleitung und eine Firewall-Prüfung erzeugen zeichengenau dieselbe Meldung. Eine echte 404 von unserer eigenen Seite, ein 3.407 Byte großes HTML-Dokument, erzeugt genau das und nichts weiter.

Die Meldung sieht aus, als zitierte sie den Server für Sie. Für den Fall, in dem Sie am wahrscheinlichsten stecken, zitiert sie den einen Teil der Antwort, den jede HTML-Seite teilt.

Weshalb die Lösung nie im Parser liegt

Prüfen Sie die Antwort, bevor Sie sie parsen, und sagen Sie, was Sie gefunden haben, wenn sie falsch ist:

const res = await fetch(url);

if (!res.ok) {
  throw new Error(`${res.status} ${res.statusText} from ${res.url}`);
}

const type = res.headers.get("content-type") || "";
if (!type.includes("application/json")) {
  throw new Error(`expected JSON, got ${type || "no content-type"} from ${res.url}`);
}

return res.json();

Beide Prüfungen verdienen ihren Platz. Die erste gibt es, weil fetch bei einem schlechten Status nicht wirft. Die zweite gibt es, weil eine 200 trotzdem HTML sein kann, was genau der Fall mit der Anmeldeseite ist, und weil sie einen unlesbaren Parser-Fehler in einen Satz verwandelt, der Status, Typ und URL nennt.

Und die Fassung, die als Ticket ankommt, ist schlimmer

Alles oben setzt voraus, dass Sie die Antwort sehen können. Die Person, die den Fehler meldet, kann das nicht.

Was ankommt, ist “da steht irgendwas von JSON”, oder im besten Fall ein Foto einer Konsolenzeile mit is not valid JSON. Diese Zeile trägt nicht den Statuscode, nicht den Content-Type, nicht die abgerufene URL und nicht den Body. Alle fünf Ursachen oben erzeugen denselben Konsolentext, und vier der fünf sind ohne die Antwort unsichtbar.

Damit ist das einer der Fehler, bei denen die Konsole allein nicht reicht und der Netzwerk-Reiter das Artefakt ist, auf das es ankommt: die Anfrage, ihr Status, ihr Content-Type und die ersten paar hundert Byte des Rückgelieferten. Den Netzwerk-Reiter zu öffnen ist eine Minute Arbeit für jemanden, der es schon getan hat, und ein anderer Nachmittag für jemanden, der es nicht hat.

Lohnt die Abgrenzung zum Nachbarn, weil Leute nach dem einen suchen und das andere haben: TypeError: Failed to fetch ist die Anfrage, die nie zustande kommt. Dieser Fehler ist die Anfrage, die perfekt zustande kommt und das Falsche zurückgibt.

Session Replay

Kostenlose Chrome-Erweiterung. Ein Klick auf die Seite, die Probleme macht, erfasst den Screenshot, die Konsole und das Netzwerkprotokoll und liefert Ihnen einen Link zum Einfügen in das Ticket.

Erweiterung installieren

Was davon zu behalten ist

Die Meldung nennt JSON, weil JSON.parse die Stelle ist, an der Ihr Programm anhielt, nicht die, an der es schiefging. Lesen Sie sie als Aussage über die Antwort: leer, oder kein JSON, oder abgeschnitten. Und sehen Sie dann die Antwort an, denn der Parser hat Ihnen schon alles gesagt, was er weiß.