Algo llama a JSON.parse, directamente o a través de res.json(), y la consola dice una de estas dos cosas:

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

Casi todo el mundo lo lee como una queja sobre su JSON y va a mirar su JSON. El JSON casi nunca es el problema. Normalmente no hay JSON en absoluto, y el parser le está contando lo primero que no pudo usar.

Estos son los mensajes que imprime Chrome. Firefox y Safari redactan los suyos de otra forma; las causas de abajo son las mismas, de cualquier navegador que venga el aviso.

Qué le dice en realidad cada mensaje

Probado en una consola de navegador, esto es lo que sale:

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)

En esa lista hay una regla de diagnóstico que nadie dice en voz alta, y es lo más útil de esta página:

  • Un mensaje con un número de posición significa que de verdad tiene JSON, y que se corta pronto o se vuelve inválido a mitad de camino. Algo truncó una carga real.
  • Un mensaje que cita un fragmento y acaba en “is not valid JSON” significa que nunca fue JSON. Le han dado otra cosa completamente distinta.
  • “Unexpected end of JSON input” significa que el parser llegó al final antes de tener un valor completo, lo que en la práctica quiere decir que el cuerpo estaba vacío. Los espacios cuentan como vacío.

Son tres errores distintos con tres soluciones distintas, y el texto del error es lo único que los separa.

Un cuerpo vacío, y las cuatro maneras de llegar a uno

“Unexpected end of JSON input” es lo que produce JSON.parse(""), así que la pregunta es por qué el cuerpo estaba vacío.

Una respuesta 204 No Content es la versión honesta: el servidor le dice que no hay nada, y llamar a .json() sobre ella es el error. Un 200 con el cuerpo vacío es la misma situación sin el aviso. Un cuerpo ya consumido también lo provoca: los cuerpos de Response son flujos y se leen una vez, así que await res.text() seguido de await res.json() falla en la segunda llamada con exactamente este mensaje. Y una petición abortada puede dejarle un cuerpo parcial o vacío según dónde se cortara.

Una página web, que es el caso frecuente

El otro mensaje es el que se va a encontrar de verdad, y significa que su petición devolvió HTML.

Es tan frecuente por un detalle de fetch con el que todo el mundo tropieza una vez: fetch no rechaza con 4xx ni 5xx. Un 404 es un fetch perfectamente exitoso para la promesa. Así que si la URL está mal, o la sesión caducó y un proxy respondió con una página de acceso, o un cortafuegos de aplicaciones web sirvió un desafío, o el servidor lanzó un 500 y su framework renderizó su propia página de error, res.json() recibe un documento que empieza por <!DOCTYPE html> e informa del <.

Una página de acceso es la peor de todas, porque suele llegar como un 200. En el código de estado no hay nada mal. El cuerpo es simplemente un formulario de inicio de sesión.

La mejora que se queda a un carácter de ser útil

Estos mensajes eran peores antes. El issue 6551 de V8, “JSON.parse errors can be user-friendly for short strings”, se abrió en 2017 y se implementó en mayo de 2022: antes, Unexpected token N in JSON at position 0, y después, "NaN" is not valid JSON. La forma nueva cita lo que recibió, que es una mejora real y la razón de que la expresión “in JSON at position 0” haya desaparecido casi del todo de los navegadores mientras sigue en todos los resultados de búsqueda sobre ella.

Esa cita tiene un límite de longitud, y medirlo vale un minuto porque no está documentado en ningún sitio. Parsee una cadena de veinte caracteres o menos y el mensaje cita la cadena entera. A partir de veintiuno cita los diez primeros caracteres y añade unos puntos suspensivos.

Cuente ahora los caracteres de <!DOCTYPE . Son exactamente diez.

Así que para la causa más frecuente de este error, el nuevo fragmento tan útil es "<!DOCTYPE "... todas las veces. Una página 404, una página 500, una redirección a un login y un desafío de cortafuegos producen carácter por carácter el mismo mensaje. Pedir un 404 real de nuestro propio sitio, un documento HTML de 3.407 bytes, produce precisamente eso y nada más.

El mensaje parece estar citándole al servidor. Para el caso en el que es más probable que esté, está citando la única parte de la respuesta que comparten todas las páginas HTML.

Y por eso la solución nunca está en el parser

Compruebe la respuesta antes de parsearla, y diga qué encontró cuando esté mal:

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();

Las dos comprobaciones se ganan su sitio. La primera existe porque fetch no lanza con un estado malo. La segunda existe porque un 200 puede seguir siendo HTML, que es exactamente el caso de la página de acceso, y porque convierte un error ilegible del parser en una frase que nombra el estado, el tipo y la URL.

Y la versión de esto que llega en un ticket es peor

Todo lo anterior supone que usted puede ver la respuesta. La persona que informa del fallo no puede.

Lo que llega es “dice algo de JSON”, o en el mejor de los casos una foto de una línea de consola que pone is not valid JSON. Esa línea no lleva el código de estado, ni el content-type, ni la URL que se pidió, ni el cuerpo. Las cinco causas de arriba producen el mismo texto en consola, y cuatro de las cinco son invisibles sin la respuesta.

Lo que hace de este uno de los errores en los que la consola por sí sola no basta y la pestaña de red es el artefacto que importa: la petición, su estado, su content-type y los primeros cientos de bytes de lo que volvió. Abrir la pestaña de red es un minuto de trabajo para quien ya lo ha hecho y otra tarde entera para quien no.

Vale la pena separarlo de su vecino, porque la gente busca uno teniendo el otro: TypeError: Failed to fetch es la petición que nunca llega a completarse. Este error es la petición que se completa perfectamente y devuelve lo que no debía.

Session Replay

Extensión gratuita para Chrome. Un clic en la página que está fallando captura la pantalla, la consola y el registro de red, y le entrega un enlace para pegar en el ticket.

Obtener la extensión

Qué quedarse de todo esto

El mensaje nombra JSON porque JSON.parse es donde se detuvo su programa, no donde se torció. Léalo como una afirmación sobre la respuesta: vacía, o no JSON, o truncada. Y vaya entonces a mirar la respuesta, porque el parser ya le ha dicho todo lo que sabe.