Alguma coisa chama JSON.parse, direto ou através de res.json(), e o console diz uma destas duas coisas:

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

Quase todo mundo lê isso como uma reclamação sobre o próprio JSON e vai olhar o próprio JSON. O JSON quase nunca é o problema. Normalmente não existe JSON nenhum, e o parser está contando a primeira coisa que ele não conseguiu usar.

Essas são as mensagens que o Chrome imprime. Firefox e Safari redigem as deles de outro jeito; as causas abaixo são as mesmas, de qualquer navegador que venha o relato.

O que cada mensagem de fato diz

Testado em um console de navegador, é isto que sai:

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)

Nessa lista há uma regra de diagnóstico que ninguém diz em voz alta, e é a coisa mais útil desta página:

  • Uma mensagem com um número de posição quer dizer que você realmente tem JSON, e que ele termina cedo ou fica inválido no meio do caminho. Algo truncou uma carga de verdade.
  • Uma mensagem que cita um trecho e termina em “is not valid JSON” quer dizer que nunca foi JSON. Entregaram para você outra coisa completamente diferente.
  • “Unexpected end of JSON input” quer dizer que o parser chegou ao fim antes de ter um valor completo, o que na prática significa corpo vazio. Espaços contam como vazio.

São três bugs diferentes com três correções diferentes, e o texto do erro é a única coisa que os separa.

Um corpo vazio, e os quatro jeitos de conseguir um

“Unexpected end of JSON input” é o que JSON.parse("") produz, então a pergunta é por que o corpo estava vazio.

Uma resposta 204 No Content é a versão honesta: o servidor diz que não há nada, e chamar .json() nela é o bug. Um 200 com corpo vazio é a mesma situação sem o aviso. Um corpo já consumido também faz isso: corpos de Response são streams e se leem uma vez, então await res.text() seguido de await res.json() falha na segunda chamada com exatamente esta mensagem. E uma requisição abortada pode deixar um corpo parcial ou vazio, dependendo de onde foi cortada.

Uma página web, que é o caso comum

A outra mensagem é a que você vai encontrar de verdade, e significa que a sua requisição devolveu HTML.

Ela é tão comum por causa de um detalhe do fetch em que todo mundo tropeça uma vez: o fetch não rejeita em 4xx nem 5xx. Um 404 é um fetch perfeitamente bem-sucedido do ponto de vista da promise. Então, se a URL está errada, ou a sessão expirou e um proxy respondeu com uma página de login, ou um firewall de aplicação web serviu um desafio, ou o servidor lançou um 500 e o seu framework renderizou a própria página de erro, o res.json() recebe um documento que começa com <!DOCTYPE html> e relata o <.

Uma página de login é a pior delas, porque costuma chegar como 200. No código de status não há nada errado. O corpo é só um formulário de entrada.

A melhoria que para a um caractere de ser útil

Essas mensagens já foram piores. A issue 6551 do V8, “JSON.parse errors can be user-friendly for short strings”, foi aberta em 2017 e implementada em maio de 2022: antes, Unexpected token N in JSON at position 0, e depois, "NaN" is not valid JSON. A forma nova cita o que recebeu, o que é uma melhoria real e o motivo de a expressão “in JSON at position 0” ter praticamente desaparecido dos navegadores enquanto continua em todo resultado de busca sobre ela.

Essa citação tem um limite de tamanho, e medi-lo vale um minuto porque não está documentado em lugar nenhum. Faça o parse de uma string de vinte caracteres ou menos e a mensagem cita a string inteira. A partir de vinte e um, ela cita os dez primeiros caracteres e acrescenta três pontos.

Agora conte os caracteres de <!DOCTYPE . São exatamente dez.

Então, para a causa mais comum desse erro, o novo trecho tão útil é "<!DOCTYPE "... todas as vezes. Uma página 404, uma página 500, um redirecionamento de login e um desafio de firewall produzem caractere por caractere a mesma mensagem. Buscar um 404 real do nosso próprio site, um documento HTML de 3.407 bytes, produz precisamente isso e nada mais.

A mensagem parece estar citando o servidor para você. Para o caso em que você mais provavelmente está, ela está citando a única parte da resposta que toda página HTML tem em comum.

E é por isso que a correção nunca está no parser

Confira a resposta antes de parsear, e diga o que encontrou quando estiver errada:

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

As duas verificações merecem o lugar. A primeira existe porque o fetch não lança em um status ruim. A segunda existe porque um 200 ainda pode ser HTML, que é exatamente o caso da página de login, e porque transforma um erro ilegível de parser em uma frase que nomeia o status, o tipo e a URL.

E a versão disso que chega num chamado é pior

Tudo acima pressupõe que você consegue ver a resposta. A pessoa que relata o problema não consegue.

O que chega é “diz alguma coisa de JSON”, ou na melhor das hipóteses uma foto de uma linha de console escrito is not valid JSON. Essa linha não carrega o código de status, nem o content-type, nem a URL que foi buscada, nem o corpo. As cinco causas acima produzem o mesmo texto no console, e quatro das cinco são invisíveis sem a resposta.

O que faz desse um dos erros em que o console sozinho não basta e a aba de rede é o artefato que importa: a requisição, o status dela, o content-type dela e as primeiras centenas de bytes do que voltou. Abrir a aba de rede é um minuto de trabalho para quem já fez e outra tarde inteira para quem não fez.

Vale separar do vizinho, porque as pessoas procuram um tendo o outro: TypeError: Failed to fetch é a requisição que nunca se completa. Este erro é a requisição que se completa perfeitamente e devolve a coisa errada.

Session Replay

Extensão gratuita para Chrome. Um clique na página que está com problema captura a tela, o console e o log de rede, e entrega um link para você colar no chamado.

Obtenha a extensão

O que levar disso

A mensagem nomeia JSON porque JSON.parse é onde o seu programa parou, não onde ele deu errado. Leia como uma afirmação sobre a resposta: vazia, ou não JSON, ou truncada. E então vá olhar a resposta, porque o parser já lhe disse tudo o que sabe.