What the message actually says
The wording is precise and almost everybody reads it wrong. It does not say “your JSON is incomplete”. It says the parser reached the end of the input while it was still expecting a value — which is a statement about the parser's state at the moment it gave up, not about the document.
A JSON parser moves through a small number of states: expecting a value, expecting a property name, expecting a separator, inside a string. When the input runs out, the message you get depends entirely on which state it was in. That is why the same damage — bytes missing from the end — produces four different sentences, and why this particular one is so often a red herring.
Everything below was produced by running the inputs through three parsers on this machine: Node 22.22.2 (V8), Python 3.13.12 (json) and MySQL 8.4.3 (CAST … AS JSON). The messages are copied character for character, not paraphrased.
Six truncations, three engines
Every row is a document that was cut short. If the message were a diagnosis, all six would say the same thing. They do not: only the first three produce the sentence you searched for.
| Input | V8 | Python | MySQL |
|---|---|---|---|
(empty)Nothing arrived at all | Unexpected end of JSON input | Expecting value: line 1 column 1 (char 0) | "The document is empty." at position 0. |
Whitespace arrived, but no value | Unexpected end of JSON input | Expecting value: line 1 column 4 (char 3) | "The document is empty." at position 3. |
{"a":Cut off immediately after the colon | Unexpected end of JSON input | Expecting value: line 1 column 6 (char 5) | "Invalid value." at position 5. |
{Cut off immediately after the opening brace | Expected property name or '}' in JSON at position 1 (line 1 column 2) | Expecting property name enclosed in double quotes: line 1 column 2 (char 1) | "Missing a name for object member." at position 1. |
[1,2Cut off inside an array | Expected ',' or ']' after array element in JSON at position 4 (line 1 column 5) | Expecting ',' delimiter: line 1 column 5 (char 4) | "Missing a comma or ']' after an array element." at position 4. |
"abcA string that never closes | Unterminated string in JSON at position 4 (line 1 column 5) | Unterminated string starting at: line 1 column 1 (char 0) | "Missing a closing quotation mark in string." at position 4. |
Read the V8 column again. The first three rows are the message you came for. Rows four, five and six are equally truncated — a brace with nothing after it, an array with no closing bracket, a quote with no closing quote — and none of them produces it. If you are hunting for missing bytes at the end of a document, this message is weak evidence.
Why only the first three
The dividing line is not how much input is missing. It is whether the parser still wanted a value.
- An empty input never read a token, so the very first thing it wanted was a value. End of input, expecting a value.
- Whitespace is discarded before parsing, so a string of spaces is the same situation as an empty one. This is worth knowing because a trailing newline from a text editor looks harmless and behaves identically.
{"a":read a property name and a colon, so the next thing required is a value. End of input, expecting a value.{is different: after an opening brace the parser wants a property name or a closing brace, not a value. Different state, different sentence.[1,2already has a complete element, so what is required next is a comma or a closing bracket."abcis inside a string literal, which has its own dedicated message in both V8 and MySQL.
So the rule of thumb is narrow but reliable: this message means the input stopped at a point where a value was due. If a value was not due, truncation announces itself with a different message, and you should stop looking for the wrong thing.
The usual cause is not truncation at all
Because the message fires when a value is due and never arrived, the most common real-world trigger is a response body of length zero rather than a half-written document. Four situations account for most reports:
- A
204 No Contentanswer, or any endpoint that returns a success status with an empty body. Networking libraries hand you an empty string, and parsing it fails exactly here. - A proxy, gateway or CDN that returned its own response and dropped the payload. The request may even be logged as successful on both ends.
- A body that was already consumed. Calling
response.json()and then reading the response again yields nothing the second time, and the second parse sees an empty string. - A JSON document assembled by string concatenation where a variable was still undefined, leaving a hole at the point a value should have been.
None of these is a syntax error in your data. In every case the parser is correctly reporting that it was handed nothing when it was promised something.
Where the three engines disagree
The table above is worth reading for a second reason: the columns do not merely use different words, they point in different directions. Three cases make that concrete.
| Input | What differs | Detail |
|---|---|---|
| Position | V8 reports no position at all. Python counts to column 4 and MySQL to position 3 — both pointing past the whitespace, at the place the value should have been. |
"abc | Direction | V8 and MySQL point at the end of the input (position 4). Python points at the start of the string (column 1). One tells you where it stopped, the other where it began. |
{"a": | Vocabulary | V8 says the input ended. MySQL says “Invalid value.” Python says it was expecting a value, which is the same thing but names the missing part instead of the absence. |
The practical consequence: any code that pattern-matches on error text to decide what went wrong is only valid for one parser. A regular expression tuned to V8 will not fire on Python, and the unterminated-string case will point at the wrong end of the document even when it does.
How to find out what actually arrived
Read the body as text before parsing it. Length and first bytes answer the question in one step, and they answer it for every one of the six cases above.
const res = await fetch(url);
// Read text first. Cloning is only needed if something else also reads the body.
const text = await res.text();
console.log(res.status, text.length, JSON.stringify(text.slice(0, 40)));
if (text.length === 0) {
throw new Error("Empty body - not a JSON problem");
}
// A byte-order mark survives text() and breaks JSON.parse one character later.
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
try {
return JSON.parse(body);
} catch (error) {
throw new Error(`Body starts with ${JSON.stringify(body.slice(0, 20))}`, {
cause: error,
});
}Three lines of logging distinguish every case in the first table. A length of zero is the empty-body case. A length that matches what DevTools shows but a first character of < is an HTML fallback. A first character of \uFEFF is a byte-order mark, which produces a different message entirely.
The fix that holds
There are two separate fixes because there are two separate problems, and applying the wrong one is why this error keeps coming back.
- If the body is empty, the fix is on the sending side: return a status that says so, or send
"null"explicitly. A client cannot distinguish “no data” from “failed silently” when both arrive as zero bytes. - If the body was consumed, clone before inspecting. Reading a response body is a one-shot operation, and a debugging line added in the wrong place will empty it for the code that follows.
- If the document really was cut off, expect a different message. Handle every parser error by its shape rather than its wording: whether a position is reported, whether the input was quoted back, and whether the failure happened at the first character or later.
One habit prevents most of this class of bug: validate before you parse, using the same input the parser will see. Paste the body into a JSON validator and the difference between an empty body, an HTML page and a truncated document becomes visible immediately, without a round trip through your application logs.
Related reading
If the first character is an angle bracket rather than nothing, the input was not truncated — it was a different document. The position 0 guide covers the SPA fallback, the 404 page and the byte-order mark that produce it, and the guide to parser messages covers the messages that arrive with a position attached. If the failure only appears after a round trip through a database column, the JSON5 guide explains the comma and comment complaints.