Parsing JSON looks like a one-liner until the input is malformed, hostile, or larger than you planned for. This guide walks through the failure modes of
JSON.parseWhy textJSON.parse Needs More Than a Single Line
JSON.parseThe canonical call is simple:
jsconst data = JSON.parse(rawText);
The problem is everything that call assumes. It assumes
rawTextlocalStorageJSON.parseSyntaxErrorJSON.parse('42')JSON.parse('null')JSON.parse('"hello"')nullAlways Wrap Parsing in try/catch
Because
JSON.parsejsfunction safeParse(text) { try { return { ok: true, value: JSON.parse(text) }; } catch (err) { return { ok: false, error: err.message }; } } const result = safeParse(rawText); if (!result.ok) { // log, fall back to a default, or return a 400 — but never proceed blindly return; } const data = result.value;
Returning a tagged object instead of throwing keeps the failure visible at the call site. The caller has to acknowledge that parsing might fail, which is exactly the discipline you want around untrusted input. A bare
try { ... } catch {}undefinedOne practical note: guard the type before you parse.
JSON.parse(undefined)"undefined"typeof text !== 'string'Reviver Functions: Transform While You Parse
JSON.parsejsconst isoDate = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; const data = JSON.parse(rawText, (key, value) => { if (typeof value === 'string' && isoDate.test(value)) { return new Date(value); } return value; });
A reviver can also strip fields you never want to carry forward. Returning
undefinedjsconst clean = JSON.parse(rawText, (key, value) => key === 'password' ? undefined : value );
Two things to keep in mind. The reviver runs bottom-up, so when it is called for the root (the empty-string key), its children have already been transformed. And a reviver is a transformer, not a validator — it can reshape values but it is awkward for asserting that the overall structure is correct. For that, validate after parsing.
Validate the Shape, Not Just the Syntax
This is the step most code skips. After a successful parse you have some JavaScript value; you do not yet know it is the value your code needs. Before you read
data.user.emaildatadata.useremailnull[]TypeErrorFor small, well-known shapes a hand-written guard is clear and dependency-free:
jsfunction isUser(value) { return ( value !== null && typeof value === 'object' && typeof value.id === 'string' && typeof value.email === 'string' && (value.age === undefined || typeof value.age === 'number') ); } const result = safeParse(rawText); if (!result.ok || !isUser(result.value)) { return; // reject: bad syntax or wrong shape } const user = result.value; // now safe to use
Note the
value !== nulltypeof value === 'object'typeof null'object'nullSchema Validation for Anything Non-Trivial
Once the shape has more than a few fields, nested objects, optional properties, enums, or arrays, hand-written guards become long and error-prone. This is where schema validation earns its place. The idea is to declare the expected structure once and let a validator both check the data and narrow its type in the same step.
The pattern, regardless of the tool, looks like this:
js// Pseudocode of the schema-validation pattern const UserSchema = object({ id: string(), email: string().email(), age: number().int().min(0).optional(), roles: array(enumOf(['admin', 'user'])), }); const parsed = safeParse(rawText); if (!parsed.ok) return reject('invalid JSON'); const check = UserSchema.validate(parsed.value); if (!check.ok) return reject(check.errors); // structured, per-field errors const user = check.value; // validated and typed
The benefits over ad-hoc checks: schemas give you precise, per-field error messages ("
emailThe rule of thumb: validate at the boundary. Every place untrusted JSON enters your program — an API handler, a webhook, a config file, a message off a queue — should validate before the data flows inward. Code past the boundary then gets to assume clean input.
Handling Untrusted Input Safely
When JSON comes from outside your trust boundary, parsing and shape-checking are necessary but not sufficient. A few extra concerns:
- and prototype pollution. A payload liketext
__proto__is valid JSON.text{"__proto__": {"isAdmin": true}}itself does not pollute the prototype, but code that later merges parsed data into an object with a naive deep-copy can. Avoid blind recursive merges of untrusted objects; if you must copy, skip the keystextJSON.parse,text__proto__, andtextconstructor, or build the target withtextprototype.textObject.create(null) - Never JSON.text
evalandtextevalwill execute embedded code and are a remote-code-execution vector.textnew Functionis the only correct tool; it cannot run code.textJSON.parse - Reject before you trust. Treat a parse failure or a schema failure as a -class outcome, log enough to diagnose it, and return a generic message to the caller. Do not echo raw parser errors back to clients — they can leak internal structure.text
400 - Bound the size. An attacker can send a multi-megabyte body specifically to exhaust memory or CPU. Enforce a maximum content length before you read the body into a string and parse it.
If you are inspecting a suspicious or unfamiliar payload by hand, a client-side viewer that runs entirely in your browser keeps the data off third-party servers — our JSON formatter is built for exactly that kind of safe inspection and pretty-printing.
Large Payloads: Where textJSON.parse Breaks Down
JSON.parseJSON.parseStrategies, roughly in order of how big the data is:
- Move it off the main thread. In the browser, parse inside a Web Worker so a large does not freeze rendering. The worker posts the result back when done.text
JSON.parse - Don't send one giant blob. If you control the API, paginate or page the data into smaller responses. Parsing ten 1 MB chunks is far gentler than one 10 MB parse, and it lets you process incrementally.
- Stream it. For data measured in hundreds of megabytes — large exports, log files, line-delimited records — a streaming parser reads the text in chunks and emits values as it goes, so you never hold the full tree in memory. On a server, processing a stream of records one at a time keeps memory flat regardless of file size. JSON Lines (one JSON object per line) is a simple, robust format for this: read line by line and each line independently, which also means one corrupt record doesn't sink the whole file.text
JSON.parse - Validate cheaply first. If you only need to confirm a large payload is well-formed before storing it, a fast well-formedness check is lighter than a full materialize-and-walk.
The meta-point:
JSON.parseA Sensible Default Pipeline
Putting it together, robust JSON handling at a boundary is four steps, not one:
- Guard the input type and bound its size before parsing.
- Parse inside try/catch, returning a result you must check.
- Validate the shape — a hand-written guard for simple data, a schema for anything real.
- Only then use the data, confident it is both well-formed and well-shaped.
Most JSON bugs come from collapsing these into the single optimistic line we started with. Treat parsing as a place where things go wrong, and the rest of your code gets to assume they didn't. If you want to eyeball or reformat a payload while debugging, the privacy-first JSON formatter handles it locally — no upload required.