A brief note on why a proxy company writes this: we are Geonode, and the single most common JSON error our customers report is Unexpected token '<' on data they fetched. That means the response was HTML — an error page, a login redirect or a block page — and the parser is reporting accurately that it was not given JSON. Before changing any code, log the first 200 characters of what you received. Almost everything else in this article assumes the file is actually JSON, and that assumption is the one that fails most often.
In Node: Reading From Disk
Three approaches, and one of them is the modern answer.
fs/promises, the standard route:
import { readFile } from "node:fs/promises";
const raw = await readFile("./data.json", "utf8");
const data = JSON.parse(raw);
The Node documentation is specific about the encoding argument, and it matters: without an encoding, readFile "returns a promise that fulfills with a <Buffer> object containing the file contents"; with one, it "fulfills with a <string>". JSON.parse accepts a Buffer by coercing it to a string, so omitting the encoding usually works and is doing extra conversion for no reason. Pass "utf8".
It also supports an AbortSignal through the signal option, "allowing you to abort an in-progress readFile operation" — useful when a read is part of a request that may be cancelled.
Synchronous, for start-up code:
import { readFileSync } from "node:fs";
const config = JSON.parse(readFileSync("./config.json", "utf8"));
Blocking is fine before your server starts serving. It is not fine inside a request handler, where it stalls the event loop for every other connection. That distinction is the whole rule.
require, in CommonJS only:
const data = require("./data.json");
Concise and carries two properties people forget. It caches, so a second require of the same path returns the same object without re-reading the file — which means editing the file at runtime has no effect. And it is unavailable in ES modules.
Import Attributes: The Standard Way
The syntax most people have not switched to, and the one to use in new code.
import data from "./data.json" with { type: "json" };
Or dynamically:
const data = await import("./data.json", { with: { type: "json" } });
MDN records this as Baseline 2025, available since April 2025 across the latest browsers, with non-browser runtimes such as Node and Deno aligning with browser semantics for JSON modules.
The type: "json" attribute is not decoration. MDN explains that it "validates that a module is served with the application/json MIME type", and that if the file "is served with any media type other than application/json, the import will fail".
The security rationale is worth quoting because it explains why the attribute is mandatory rather than optional:
If, for some reason (e.g., the server is hijacked or bogus), the media type in the server response is set to
text/javascript(for JavaScript source), then the file would be parsed and executed as code. If the "JSON" file actually contains malicious code, theimportdeclaration would unintentionally execute external code, posing a serious threat.
One migration note: an earlier proposal used an assert keyword instead of with. MDN flags this as a breaking change — implementations using assert "are no longer supported". If you find assert { type: "json" } in older code or a tutorial, it needs updating.
In the Browser: Fetching JSON
The common case, and the one with a trap.
const res = await fetch("/data.json");
if (!res.ok) throw new Error(`HTTP ${res.status} from ${res.url}`);
const data = await res.json();
The res.ok check is not optional, and skipping it is the direct cause of the error in this article's introduction. fetch does not reject on HTTP error statuses — a 403, a 404 and a 500 all resolve normally. Calling .json() then attempts to parse an error page, and you get a syntax error about a < character that has nothing to do with your JSON.
For a version that fails usefully:
async function fetchJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
const type = res.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
const body = await res.text();
throw new Error(`Expected JSON, got ${type}: ${body.slice(0, 200)}`);
}
return res.json();
}
Two lines of checking convert an opaque parse error into a message naming the status, the content type and what actually arrived.
Note also that Response.json() does not accept a reviver function. If you need one — for date conversion or for handling large integers — use res.text() followed by JSON.parse. We covered revivers and the precision problem in our guide to JSON.parse.
In the Browser: A File the User Chose
For a file picked from the user's machine, use the File API.
<input type="file" id="picker" accept="application/json">
document.getElementById("picker").addEventListener("change", async e => {
const file = e.target.files[0];
if (!file) return;
try {
const data = JSON.parse(await file.text());
console.log(data);
} catch (err) {
console.error(`Could not parse ${file.name}: ${err.message}`);
}
});
File.text() returns a promise resolving to the contents as a string, which is considerably tidier than the older FileReader with its event handlers. FileReader is still what you need if you want progress events on a very large file.
Two things to remember. Browsers cannot read arbitrary local paths — the user must choose the file, and that is a deliberate security boundary rather than a limitation to route around. And a .json extension guarantees nothing about the contents, so the try/catch is doing real work.
Drag-and-drop uses the same File objects, obtained from event.dataTransfer.files.
Error Handling That Tells You Something
The habit that separates a five-minute problem from an hour.
function parseJson(text, source) {
try {
return JSON.parse(text);
} catch (err) {
throw new Error(
`Failed to parse JSON from ${source}: ${err.message}. ` +
`First 200 chars: ${text.slice(0, 200)}`
);
}
}
The slice is what matters. JSON.parse errors name a position and a character; the actual input tells you why. Three signatures cover most cases:
Unexpected token '<' — the content is HTML. An error page, a login redirect, or a directory listing.
Unexpected end of JSON input — the input is empty or truncated. A 204 response, a file that failed to write completely, or a fetch you forgot to await.
Unexpected token '}' at a plausible position — genuinely malformed JSON, frequently a trailing comma. JSON forbids them even though JavaScript permits them.
For file reads, distinguish the read failure from the parse failure. ENOENT means the file does not exist, which is a different problem from invalid contents and deserves a different message.
Validating What You Read
Parsing succeeded. That tells you the syntax was valid and nothing whatever about whether the data is the shape your code expects — and the gap between those two is where a surprising share of production failures live.
Parsing and validation are separate steps. JSON.parse will happily return {"user": {"nmae": "Ada"}} with a typo in the key, or a price field containing the string "n/a" where a number was expected. Your code then fails somewhere downstream, several functions away from the actual problem, with an error that names a symptom rather than a cause.
For anything from outside your control, validate against a schema. Several libraries handle this well, and the pattern is the same regardless of which you pick:
const Config = z.object({
port: z.number().int().min(1).max(65535),
host: z.string(),
retries: z.number().int().default(3),
features: z.array(z.string()).optional(),
});
const config = Config.parse(JSON.parse(await readFile("./config.json", "utf8")));
The failure now names the field, the expected type and what was found — which is the difference between a five-minute fix and an afternoon.
For simple cases, a few assertions cost nothing:
const data = JSON.parse(raw);
if (!Array.isArray(data.items)) throw new Error("items must be an array");
if (data.items.length === 0) throw new Error("items is empty — check the source");
That second check is worth more than it looks. An empty array is valid JSON, parses fine, and is very often a symptom rather than a legitimate result — an API that returned nothing because a filter was wrong, or a scrape that succeeded against a page which had changed.
Be sceptical of numbers you did not produce. JSON numbers become JavaScript doubles, so integers above Number.MAX_SAFE_INTEGER lose precision silently, with no error at any point. Identifiers are the usual casualty: two distinct database records can parse to the same value. If a field is an identifier rather than a quantity, it should be a string in the JSON — and if you do not control the producer, the reviver's context.source argument gives you the original digits.
And validate at the boundary, once. Checking the shape where the data enters your program means everything downstream can assume it is correct. Checking defensively in twenty places means twenty places to update and no single point where the contract is written down.
Large Files
JSON.parse is synchronous and needs the whole document in memory. Both become problems as files grow.
Rough guidance. Under a megabyte, do not think about it. Between one and ten, measure — particularly on the main thread in a browser, where parsing blocks rendering and produces visible jank. Above ten megabytes, or above roughly a tenth of your available memory, do something else.
Move it off the main thread. In a browser, a Web Worker parses without freezing the interface. In Node, a worker thread does the same for the event loop.
Use newline-delimited JSON. This is the structural fix rather than a workaround. One JSON document per line means you process a file of any size in constant memory, each line parses independently, and a truncated file still yields every complete record:
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const rl = createInterface({ input: createReadStream("./data.jsonl") });
for await (const line of rl) {
if (line.trim()) handle(JSON.parse(line));
}
If you control the format, this is the better design for anything appended to over time — and the reason a crashed collection job leaves a usable file rather than an unparseable one.
Use a streaming parser when the format is a single large array you cannot change. Several libraries emit values as they arrive rather than building the whole tree.
Or ask for less. Pagination, field selection, a narrower endpoint. Almost always the correct answer and almost always skipped because it requires talking to whoever owns the API.
Writing JSON Back
The mirror image, briefly, since it is usually the next question.
import { writeFile } from "node:fs/promises";
await writeFile("./out.json", JSON.stringify(data, null, 2), "utf8");
The null, 2 arguments produce indented output, which matters more than it seems: a file that will ever be read by a human or diffed in version control should be formatted, and one that is transmitted should not be.
Three things that do not survive JSON.stringify, and produce silent data loss rather than errors. undefined values and functions are dropped from objects entirely and become null inside arrays. Date objects become ISO strings, so they do not round-trip back to dates without a reviver. And BigInt throws outright — the standard approach is to serialise large integers as strings.
For appending, write newline-delimited JSON rather than rewriting an array:
import { appendFile } from "node:fs/promises";
await appendFile("./log.jsonl", JSON.stringify(record) + "\n", "utf8");
Appending to a JSON array requires reading it, parsing it, pushing and rewriting the whole file — which is expensive, and which corrupts the file if the process dies mid-write.
People Also Ask
How do I read a JSON file in Node.js?
const data = JSON.parse(await readFile("./data.json", "utf8")) using node:fs/promises. Or use the standard import syntax: import data from "./data.json" with { type: "json" }, which is Baseline as of 2025 and works in both Node and browsers.
Can JavaScript read a local file in the browser?
Not by path. Browsers cannot open arbitrary local files, which is a deliberate security boundary. The user must choose a file through an <input type="file"> or drag-and-drop, after which File.text() gives you the contents.
What does import ... with { type: "json" } do?
It imports a JSON file as a module while validating that the server served it with the application/json MIME type. Without that check, a file served as text/javascript would be parsed and executed as code — which is the security problem the attribute exists to prevent.
Why do I get "Unexpected token '<'" when reading JSON?
Because the content starts with <, meaning you received HTML rather than JSON — usually an error page or a login redirect. With fetch, the cause is almost always a missing res.ok check, since fetch does not reject on HTTP error statuses.
Should I use require or import for JSON?
import ... with { type: "json" } in new code, since it is the standard and works in ES modules. require is CommonJS only and caches the result, so a file edited at runtime will not be re-read. Neither is right for files that change while your program runs — use readFile for those.
How do I read a very large JSON file?
Move parsing off the main thread with a worker, or restructure the data as newline-delimited JSON so each line parses independently in constant memory. For an unchangeable large array, use a streaming parser. And consider whether you can request less data in the first place.
What is the difference between JSON.parse and response.json()?
Response.json() reads a fetch body and parses it in one step, and does not accept a reviver function. JSON.parse works on a string you already have and does. If you need a reviver — for dates or large integers — use res.text() followed by JSON.parse.
How do I handle a JSON file that might not exist?
Catch the read error separately from the parse error. In Node, an ENOENT error code means the file is missing, which usually calls for a default value rather than a failure — whereas a SyntaxError means the file exists and its contents are wrong.
Wrapping Up
The method depends on the environment, and the modern answer is more uniform than it used to be. import data from "./data.json" with { type: "json" } works in Node and in browsers, is Baseline as of 2025, and carries a MIME-type check that exists for a real security reason rather than as ceremony.
For files that change while your program runs, read them explicitly — readFile with a "utf8" encoding in Node, fetch with an res.ok check in a browser, and File.text() for something the user chose. That res.ok check is the single highest-value line in this article, because skipping it is the direct cause of the most common JSON error there is.
When something fails, log the first 200 characters of the input before touching any code. Unexpected token '<' means HTML, Unexpected end of JSON input means empty or truncated, and both are answered by looking at what actually arrived rather than by reasoning about the parser.
And if the files are getting large, the structural fix is newline-delimited JSON rather than a bigger machine. One document per line streams in constant memory, appends safely, and survives an interrupted write with every complete record intact.
