Quick answer:
SyntaxError: Unexpected end of JSON inputmeansJSON.parse()ran out of text before the value was finished. Nine times out of ten the input was completely empty: a 204 response, an emptylocalStoragevalue, or a request whose body was never read. The rest of the time the text was cut off partway, by a timeout, a truncated file, or a stream chunk parsed too early. Log the input length and its last 20 characters and the cause is immediate.
A delete button works on the server (the row is gone), yet the UI shows an error toast every time. The handler does await res.json() on a response whose body is zero bytes long, because the API returns 204 on success. The parser is asked to find a JSON value in nothing, and it reports exactly that. The fix is three lines; the time-sink is realizing the error is about length, not syntax.
Repair a copy of the failing payload with the JSON Fixer, then review the changes before using the result.
What the Error Is Telling You
JSON.parse reads character by character, tracking what it still needs: a closing brace, a closing quote, the rest of a number. If the string ends while that list is non-empty, it throws this error. The two inputs that produce it most often are the empty string and a document missing its last bracket:
JSON.parse("");
// SyntaxError: Unexpected end of JSON input
JSON.parse('{"id": 1, "tags": ["a", "b"');
// SyntaxError: Unexpected end of JSON input (older V8)
// SyntaxError: Expected ',' or ']' after array element in JSON at position 27 (newer V8)
JSON.parse(null); // null -- no error, "null" is valid JSON
Newer V8 versions phrase the truncated-document case more specifically, naming what was expected. The empty-input case still says "Unexpected end of JSON input" everywhere. Firefox reports both as JSON.parse: unexpected end of data at line 1 column 1 of the JSON data.
Contrast this with Unexpected token in JSON at position 0: that one means the text started with the wrong character. This one means the text stopped too soon, or never started.
The Six Causes at a Glance
| Cause | Typical situation | Fix |
|---|---|---|
| Empty response body | 204, HEAD, or an endpoint that returns nothing | Check status and Content-Length before parsing |
| Truncated download | Timeout, dropped connection, proxy limit | Compare received length to Content-Length; retry |
| response.json() on an empty body | DELETE or PUT that succeeds with no body | Read text first; parse only if non-empty |
| Empty string from storage | localStorage.setItem(key, '') or a cleared value | Guard with if (raw) before JSON.parse |
| Stream chunk parsed early | Parsing on the first 'data' event | Buffer until 'end', or use JSON Lines |
| Missing closing brace or bracket | Hand-edited file, failed write, merge conflict | Validate the file; rewrite atomically |
Cause 1: The Response Body Is Empty
HTTP allows responses with no body, and several are routine. 204 No Content is the standard reply to a successful DELETE. A HEAD request never carries a body by definition. A 304 Not Modified has none either. And plenty of hand-written endpoints end with res.end() or return Response(status=200) and send nothing on success.
Calling res.json() on any of these fails. The guard is to check the status, or the content length, before parsing:
const res = await fetch("/api/items/42", { method: "DELETE" });
if (!res.ok) throw new Error("HTTP " + res.status);
if (res.status === 204 || res.headers.get("content-length") === "0") {
return null;
}
return res.json();
If you own the API, the cleaner fix is on the server: either return 204 consistently (and have the client expect it) or always return a JSON body, even if it is just {"ok": true}. Mixing the two across endpoints is what makes this bug recur.
Cause 2: The Download Was Truncated
The server sent a complete document; the client received the first part. Reasons: a request timeout fired mid-body, the connection dropped, a proxy or CDN enforced a size limit, or a serverless function hit its execution limit while streaming. The received text looks like real JSON and ends mid-value.
Confirm it
const text = await res.text();
console.log(text.length, res.headers.get("content-length"));
console.log(JSON.stringify(text.slice(-40)));
If the lengths disagree, or the last 40 characters are not ...} or ...], the body is incomplete. Content-Length is absent on chunked responses, so the tail check is the more reliable of the two.
Fix it
Retry with a longer timeout, add pagination so each response is smaller, or switch very large payloads to JSON Lines, where a truncated download loses only the last line rather than the whole document. Check the proxy or gateway in front of the API as well; most have a response size or time limit that cuts bodies off silently.
Cause 3: response.json() in the Wrong Branch
A subtle variant of cause 1. The success path is fine, but the error handler tries to read a JSON error body from a response that has none:
const res = await fetch(url);
if (!res.ok) {
const err = await res.json(); // throws if the 500 page is empty or HTML
throw new Error(err.message);
}
The original failure (the 500) is now masked by a parse error, which sends you debugging the wrong thing. Read the body as text in error paths, and parse it only if it looks like JSON:
async function readBody(res) {
const text = await res.text();
if (!text) return null;
const type = res.headers.get("content-type") || "";
return type.includes("json") ? JSON.parse(text) : text;
}
Because a body can be read only once, this helper replaces res.json() entirely rather than running alongside it. The fetch JSON guide has the full pattern with timeouts and typed results.
Cause 4: An Empty String From localStorage or a Form
localStorage.getItem() returns null for a missing key, and JSON.parse(null) is fine. But a key that was set to an empty string, by a bug or by a "clear" function that wrote "" instead of calling removeItem, returns "", and that throws. The same applies to sessionStorage, cookies, hidden form inputs, data- attributes, and environment variables that are defined but blank.
function readStored(key, fallback) {
const raw = localStorage.getItem(key);
if (raw === null || raw === "") return fallback;
try {
return JSON.parse(raw);
} catch {
localStorage.removeItem(key); // corrupted; do not fail forever
return fallback;
}
}
const cart = readStored("cart", []);
The removeItem in the catch matters. Without it, a single corrupted value breaks the feature on every page load until the user clears site data.
Cause 5: A Stream Chunk Parsed Too Early
Node streams, http.IncomingMessage, WebSocket frames, and the fetch body.getReader() API all deliver data in chunks. A chunk boundary falls wherever the network put it, which is usually in the middle of the JSON. Parsing on the first chunk is a classic:
// Wrong: the first chunk is rarely the whole document
req.on("data", (chunk) => {
const body = JSON.parse(chunk); // Unexpected end of JSON input
});
// Right: accumulate, then parse once
let chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
});
If you are reading a long-lived stream of many objects (logs, events, server-sent updates), the right design is one JSON value per line, so each newline marks a safe parse point. That is the whole reason JSON Lines exists.
Cause 6: A Missing Closing Brace in a File
Hand-edited config files lose their last } more often than you would think: a deleted line that took the brace with it, a merge conflict resolved by hand, an editor that crashed before saving. Files can also be truncated on write, when a process is killed between truncating the file and finishing the write, leaving a half-document or a zero-byte file on disk.
Confirm it
Paste the file into JSON Prettifier. The validator reports the error at the final line, which is the signature of a truncated document; an error in the middle would be a different problem. Formatting the file also makes the nesting visible, so you can see which level never closed. On the command line, tail -c 50 config.json shows the last bytes.
Fix it
Add the missing bracket and validate again. Then prevent the write-time version by writing files atomically: write to a temporary file and rename it over the original, as shown in the Node.js read and write JSON guide. A rename either happens or does not; it never leaves half a file behind.
Diagnosis Checklist
- Log the length.
console.log(text.length). Zero means causes 1, 3, or 4. Non-zero means 2, 5, or 6. - Log the tail.
JSON.stringify(text.slice(-40)). A complete document ends in}or]. - Check the status and headers for network responses: 204, missing or zero
Content-Length, andTransfer-Encoding: chunkedall point somewhere specific. - Check where the text came from. Storage, a stream event, or a file each have one characteristic failure, listed above.
- Validate the text if it is non-empty. The reported position tells you whether it was cut off at the end or broken in the middle.
A Guard You Can Reuse
Most of the fixes above collapse into one helper: never call JSON.parse on text you have not looked at.
function parseJsonOr(text, fallback) {
if (typeof text !== "string" || text.trim() === "") return fallback;
try {
return JSON.parse(text);
} catch (err) {
console.warn("Invalid JSON (" + text.length + " chars): " + err.message);
return fallback;
}
}
Use the fallback deliberately. Returning {} for a settings file that failed to load is reasonable; returning [] for an order list that was truncated hides data loss. For the second kind, let it throw, and let the error name the source. The broader JSON parse error guide covers the other messages JSON.parse can produce, and common JSON errors covers the syntax slips that cause them.
If you attempt repair after a terminated response, the JSON repair acceptance checklist explains why closing punctuation cannot establish completeness or restore records that never arrived.