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
}
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
}
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
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
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,
})
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
}
Tuple style if you prefer Go-like errors:
import { tryParse } from 'handlejson'
const [data, error] = tryParse(raw)
if (error) {
console.log(error.message)
}
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'
}
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'),
})
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: {} })
Browser localStorage:
const saved = parse(localStorage.getItem('prefs'), { default: null })
Webhook with security limits:
const event = parse(req.body, {
maxSize: 1024 * 1024,
maxDepth: 50,
safeKeys: true,
default: null,
})
When try/catch is still the right choice
Do not eliminate throwing everywhere.
- You need the native
SyntaxErrortype 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
import { parse, stringify, parseWithDetails, tryValidate, parseStream } from 'handlejson'
const data = parse('{"name":"John"}')
const json = stringify({ name: 'John' })
Checklist
- [ ] Replace repeated try/catch blocks with one shared parse helper
- [ ] Add
defaultfallbacks for non-critical paths (cache, optional config) - [ ] Enable
safeKeys+maxSizefor external input - [ ] Use
parseWithDetailsonly in debug/admin paths - [ ] Keep native
JSON.parsewhere 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)