DEV Community

Cover image for Safe JSON Parsing in Node.js Without try/catch (2026 Guide)
Chintan Shah
Chintan Shah

Posted on • Edited on

Safe JSON Parsing in Node.js Without try/catch (2026 Guide)

JSON.parse throws on invalid input. That is by design. The problem is not parsing itself. It is copying the same try/catch block across every API response, config file, cache read, and user input handler in your codebase.

This guide covers practical patterns for safe JSON parsing in Node.js without try/catch spam: a small DIY wrapper, default-value parsing, security hardening for untrusted input, and when you should still let errors throw.


TL;DR

Situation Approach
Quick null fallback DIY safeParse or parse(str)
Always need an object/array parse(str, { default: {} })
Untrusted user/API input maxSize, maxDepth, safeKeys
Debugging malformed JSON parseWithDetails or tryParse
Large files (>100MB) Stream parsing
You need the exact SyntaxError Keep try/catch

The problem with JSON.parse

Every Node.js developer has written this:

let data
try {
  data = JSON.parse(str)
} catch {
  data = null
}
Enter fullscreen mode Exit fullscreen mode

It works. It is also repetitive. API handlers, Redis cache reads, localStorage wrappers, webhook parsers, and config loaders all end up with the same five lines.

JSON.parse also returns any unless you add your own typing. Invalid JSON throws. Valid JSON with dangerous keys (__proto__) can still cause prototype pollution if you merge objects blindly.


Pattern 1: Minimal safe parse (DIY)

For a one-off script, a tiny helper is enough:

function safeParse<T = unknown>(input: string): T | null {
  try {
    return JSON.parse(input) as T
  } catch {
    return null
  }
}

const user = safeParse<{ name: string }>(responseText)
if (!user) {
  // handle invalid JSON
}
Enter fullscreen mode Exit fullscreen mode

Pros: Zero dependencies, obvious behavior

Cons: No size limits, no prototype pollution guard, no error position, copy-paste across files

This pattern is fine for internal tools. It gets painful in production codebases with dozens of parse sites.


Pattern 2: Parse with defaults

When invalid JSON should not crash the flow but you still need a concrete fallback:

import { parse } from 'handlejson'

const config = parse(fileContents, { default: {} })
// {} if invalid — never throws

const user = parse(apiBody, { default: null })
// null if invalid
Enter fullscreen mode Exit fullscreen mode

Same idea as the DIY wrapper, but consistent API across the project and TypeScript inference:

type User = { name: string; age: number }
const user = parse<User>(raw)
// User | null
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Untrusted input (security)

Parsing JSON from users, webhooks, or third-party APIs needs more than null fallbacks.

Three real attack vectors:

Option Blocks
maxSize Huge payloads that exhaust memory
maxDepth Deeply nested JSON that blows the stack
safeKeys Prototype pollution via __proto__, constructor, prototype
import { parse } from 'handlejson'

const userData = parse(untrustedInput, {
  maxSize: 10 * 1024 * 1024,
  maxDepth: 100,
  safeKeys: true,
})
Enter fullscreen mode Exit fullscreen mode

Native JSON.parse does none of this. You can build it yourself, but most teams skip it until security review asks why it is missing.


Pattern 4: Know where parsing failed

null on error is clean for production paths. Debugging is easier with position and context:

import { parseWithDetails } from 'handlejson'

const result = parseWithDetails('{"name":"John", invalid}')
if (!result.success) {
  console.log(result.position)  // 18
  console.log(result.context)   // surrounding text
  console.log(result.error)     // detailed message
}
Enter fullscreen mode Exit fullscreen mode

Tuple style if you prefer Go-like errors:

import { tryParse } from 'handlejson'

const [data, error] = tryParse(raw)
if (error) {
  console.log(error.message)
}
Enter fullscreen mode Exit fullscreen mode

Use this in development, admin tools, or import pipelines where someone needs to fix the file.


Pattern 5: Validate shape without Zod

When you need basic type checks but do not want a 300KB schema library:

import { parse, tryValidate } from 'handlejson'

const schema = {
  name: 'string',
  age: '?number',
  email: '?string',
}

const data = parse(apiResponse, { schema })
// null if JSON is invalid OR schema fails

const [valid, error] = tryValidate(parsed, schema)
if (!valid) {
  console.log(error.path)       // 'age'
  console.log(error.expected)   // 'number'
  console.log(error.actual)     // 'string'
}
Enter fullscreen mode Exit fullscreen mode

Good for API response guards and config files. Not a replacement for Zod when you need complex refinements.


Pattern 6: Large JSON files

JSON.parse loads the entire string into memory. For large exports or log dumps, stream in chunks:

import { createReadStream } from 'fs'
import { parseStream } from 'handlejson'

const stream = createReadStream('./large-export.json')
const result = await parseStream(stream, {
  onProgress: (parsed) => console.log('chunk parsed'),
})
Enter fullscreen mode Exit fullscreen mode

If the file is under ~50MB and memory is not tight, plain parse is simpler.


Real-world examples

Fetch API response:

const response = await fetch('/api/user')
const user = parse(await response.text(), { default: {} })
Enter fullscreen mode Exit fullscreen mode

Browser localStorage:

const saved = parse(localStorage.getItem('prefs'), { default: null })
Enter fullscreen mode Exit fullscreen mode

Webhook with security limits:

const event = parse(req.body, {
  maxSize: 1024 * 1024,
  maxDepth: 50,
  safeKeys: true,
  default: null,
})
Enter fullscreen mode Exit fullscreen mode

When try/catch is still the right choice

Do not eliminate throwing everywhere.

  • You need the native SyntaxError type for a library API
  • Invalid JSON is exceptional and should halt a batch job immediately
  • You are writing a JSON linter or code editor and must surface exact token errors to users
  • A framework expects thrown errors from parsers

For application glue code (APIs, config, cache), null-or-default parsing is usually clearer.


Performance

handlejson adds a thin wrapper over native parse. Rough numbers on small JSON (<1KB):

Approach Throughput
Native JSON.parse ~6M ops/s
handlejson parse ~5.2M ops/s
With security options ~3.4M ops/s

For most HTTP APIs and background jobs, that gap is invisible. Profile before optimizing.


Comparison at a glance

Approach Deps Throws Security opts Error position
Native JSON.parse 0 Yes No Vague
DIY safeParse 0 No No No
handlejson 0 No Yes Yes
Zod + parse 1+ On schema fail Via custom code Via Zod

FAQ

How do I parse JSON safely in Node.js without try/catch?

Wrap JSON.parse in a helper that returns null on failure, or use a library like handlejson that does this with parse(str) and optional { default: value }.

What is prototype pollution in JSON.parse?

If parsed JSON contains keys like __proto__ and you merge objects unsafely, attacker-controlled data can pollute Object.prototype. Use safeKeys: true or sanitize keys before merge.

Is JSON.parse slow?

No. It is among the fastest options. Wrappers add small overhead. Security checks add more. The tradeoff is safety and ergonomics, not raw speed.

Should I use Zod or a safe parse wrapper?

Different jobs. Safe parse handles invalid JSON strings. Zod validates shape after parsing. Many teams use both: safe parse first, Zod for complex schemas.


handlejson (zero-dependency option)

If you want the patterns above in one package:

npm install handlejson
Enter fullscreen mode Exit fullscreen mode
import { parse, stringify, parseWithDetails, tryValidate, parseStream } from 'handlejson'

const data = parse('{"name":"John"}')
const json = stringify({ name: 'John' })
Enter fullscreen mode Exit fullscreen mode
  • Zero dependencies, ~1.5KB gzipped
  • TypeScript-first
  • npm | GitHub | Docs

Checklist

  • [ ] Replace repeated try/catch blocks with one shared parse helper
  • [ ] Add default fallbacks for non-critical paths (cache, optional config)
  • [ ] Enable safeKeys + maxSize for external input
  • [ ] Use parseWithDetails only in debug/admin paths
  • [ ] Keep native JSON.parse where throwing is the correct API contract

If you have a pattern that worked well in a high-traffic API, share it in the comments.


About handlejson (project background)

I built handlejson after one too many projects where every API response, localStorage read, and config file needed the same try/catch around JSON.parse. Existing options were either heavy, missing security features, or pulled in dependencies I did not want.

v1.0.0 (January 2026) is production-ready: 244 tests, zero dependencies, ~1.5KB gzipped, CI on Node 18/20/22.

Since v0.2.0:

  • tryValidate() with path, expected, and actual on failure
  • Optional schema fields ('?number')
  • Array item validation
  • Stream parsing for large files
  • Faster sanitizeKeys (only copies objects when dangerous keys exist)
  • ~5.2M ops/s on small JSON (vs ~6M native)

Full launch write-up archived in the repo: seo-content/handlejson/post-1-original-v1-launch-archive.md

Earlier background: introducing handlejson (v0.2.0)

Top comments (0)