safe-stable-stringify vs fast-safe-stringify vs json-stringify-safe vs safe-json-stringify
Handling Circular References and Deterministic Output in JSON Serialization
safe-stable-stringifyfast-safe-stringifyjson-stringify-safesafe-json-stringifySimilar Packages:

Handling Circular References and Deterministic Output in JSON Serialization

Standard JSON.stringify() crashes when encountering circular references, a common issue in complex state management, logging, and error reporting. The packages fast-safe-stringify, json-stringify-safe, safe-json-stringify, and safe-stable-stringify solve this by detecting cycles and replacing them with placeholder strings like "[Circular]". Beyond safety, they differ significantly in performance, memory usage, and whether they guarantee a consistent key order (determinism), which is critical for hashing, caching, and testing.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
safe-stable-stringify47,088,54424930.7 kB62 years agoMIT
fast-safe-stringify37,040,400359-95 years agoMIT
json-stringify-safe0556-711 years agoISC
safe-json-stringify057-38 years agoMIT

Safe JSON Serialization: Performance, Stability, and Circular Reference Handling

In JavaScript, JSON.stringify() is the standard tool for converting objects to strings. However, it has a fatal flaw: it throws a TypeError if the object graph contains a circular reference. This happens frequently in real-world apps—think of DOM nodes, complex error objects with cause chains, or mutually referenced state models.

To fix this, developers turn to "safe" stringifiers. But not all are built the same. Some prioritize raw speed, others guarantee that the output string is always identical (deterministic), and some are simply outdated. Let's break down how fast-safe-stringify, json-stringify-safe, safe-json-stringify, and safe-stable-stringify actually work under the hood.

🛡️ Core Safety: Handling Circular References

All four packages solve the crash problem by detecting cycles and replacing the circular link with a placeholder string (usually "[Circular]"). However, the way they detect these cycles impacts performance.

fast-safe-stringify uses a highly optimized approach that avoids heavy memory overhead. It detects cycles quickly without slowing down the serialization of normal objects.

import stringify from 'fast-safe-stringify';

const obj = { name: 'Alice' };
obj.self = obj; // Circular reference

// Output: '{"name":"Alice","self":"[Circular]"}'
console.log(stringify(obj));

json-stringify-safe was the pioneer in this space. It works reliably but uses an older detection method that can be slower on deeply nested structures.

import stringify from 'json-stringify-safe';

const obj = { name: 'Alice' };
obj.self = obj;

// Output: '{"name":"Alice","self":"[Circular]"}'
console.log(stringify(obj));

safe-json-stringify provides similar safety guarantees. It traverses the object and swaps out circular refs, ensuring your app doesn't crash.

import stringify from 'safe-json-stringify';

const obj = { name: 'Alice' };
obj.self = obj;

// Output: '{"name":"Alice","self":"[Circular]"}'
console.log(stringify(obj));

safe-stable-stringify also handles cycles safely, but it does extra work to ensure the order of keys remains consistent, which we will discuss in the next section.

import stringify from 'safe-stable-stringify';

const obj = { name: 'Alice' };
obj.self = obj;

// Output: '{"name":"Alice","self":"[Circular]"}'
console.log(stringify(obj));

⚡ Performance: Raw Speed vs. Extra Features

When serializing large logs or high-frequency data, speed matters. The trade-off here is usually between "fastest possible" and "deterministic output."

fast-safe-stringify lives up to its name. It is currently the fastest option for most use cases where key order doesn't matter. It mimics the native JSON.stringify behavior closely, preserving the insertion order of keys while adding cycle detection.

// Best for high-volume logging where order doesn't matter
import stringify from 'fast-safe-stringify';

const largeData = generateHugeObject();
const json = stringify(largeData); // Minimal overhead

json-stringify-safe and safe-json-stringify are noticeably slower. They were written years ago when JavaScript engines were less optimized for these patterns. In modern benchmarks, they often lag behind fast-safe-stringify by a significant margin.

// Legacy performance profile
import stringify from 'json-stringify-safe';

const json = stringify(largeData); // Higher CPU usage compared to modern libs

safe-stable-stringify sacrifices some raw speed to guarantee stability. Sorting keys or enforcing a specific traversal order takes extra computation. If you need stability, this cost is worth it; if you just need speed, it might be overkill.

// Pays a small performance tax for deterministic output
import stringify from 'safe-stable-stringify';

const json = stringify(largeData); // Slower than fast-safe-stringify, but stable

🔒 Determinism: Why Key Order Matters

This is the most critical architectural difference. Native JSON.stringify outputs keys in the order they were added to the object. If you create an object with keys {a, b} in one run and {b, a} in another (perhaps due to refactoring or different code paths), the resulting JSON strings will be different, even if the data is the same.

This breaks:

  • Content-based caching (CDN misses when it shouldn't)
  • Object hashing (signatures fail)
  • Snapshot testing (tests fail despite no logic change)

fast-safe-stringify, json-stringify-safe, and safe-json-stringify do NOT guarantee stable key ordering. They preserve insertion order. If your code creates objects differently, your strings will differ.

// UNSTABLE: Output depends on how 'obj' was constructed
import stringify from 'fast-safe-stringify';

const obj1 = { a: 1, b: 2 };
const obj2 = { b: 2, a: 1 };

console.log(stringify(obj1)); // '{"a":1,"b":2}'
console.log(stringify(obj2)); // '{"b":2,"a":1}' -> Different string!

safe-stable-stringify is the only one in this list designed specifically to solve this. It recursively sorts keys or uses a deterministic traversal strategy so that equivalent objects always produce the exact same string, regardless of creation order.

// STABLE: Output is always sorted/consistent
import stringify from 'safe-stable-stringify';

const obj1 = { a: 1, b: 2 };
const obj2 = { b: 2, a: 1 };

console.log(stringify(obj1)); // '{"a":1,"b":2}'
console.log(stringify(obj2)); // '{"a":1,"b":2}' -> Identical string!

🏗️ Architecture & Maintenance Status

Choosing a library isn't just about features; it's about long-term viability.

json-stringify-safe is effectively deprecated for new work. It hasn't seen significant updates in years. While it still works, relying on unmaintained packages for core infrastructure like serialization introduces unnecessary risk.

safe-json-stringify suffers from similar issues. It is a simple utility that hasn't evolved to match modern engine optimizations.

fast-safe-stringify is actively maintained and widely adopted in the ecosystem (used by major logging libraries). It strikes the best balance for general use.

safe-stable-stringify is also actively maintained and has become the standard for tools requiring reproducibility, such as build tools, testing frameworks, and cryptographic utilities.

📊 Real-World Selection Guide

Scenario 1: High-Volume Application Logging

You are logging thousands of request objects per second. Some objects might have circular refs (e.g., from Express request contexts). You need maximum throughput.

  • Choice: fast-safe-stringify
  • Why: You don't care if key order changes between runs; you just need the log to not crash and to be fast.
logger.info(fastSafeStringify(reqObject));

Scenario 2: Generating Cache Keys or Hashes

You are creating a cache key based on a configuration object. If the string representation changes due to key ordering, the cache lookup will fail, causing performance issues.

  • Choice: safe-stable-stringify
  • Why: You must ensure { port: 80, host: 'localhost' } produces the same hash as { host: 'localhost', port: 80 }.
const cacheKey = crypto.createHash('md5')
  .update(safeStableStringify(config))
  .digest('hex');

Scenario 3: Legacy System Maintenance

You are fixing a bug in a 7-year-old Node.js service that already uses json-stringify-safe.

  • Choice: Keep json-stringify-safe (or refactor if possible)
  • Why: Changing serialization logic in legacy systems can introduce subtle bugs if tests rely on specific (non-deterministic) string outputs. Only switch if you have full test coverage.
// Existing legacy code
const payload = jsonStringifySafe(complexErrorObject);

📌 Summary Comparison

Featurefast-safe-stringifyjson-stringify-safesafe-json-stringifysafe-stable-stringify
Circular Safety✅ Yes✅ Yes✅ Yes✅ Yes
Performance🚀 Very Fast🐢 Slow🐢 Slow⚡ Fast (with overhead)
Deterministic Keys❌ No (Insertion Order)❌ No❌ NoYes (Sorted/Stable)
Maintenance Status🟢 Active🔴 Inactive/Legacy🟡 Low Activity🟢 Active
Best Use CaseLogging, APIsLegacy SupportLegacy SupportHashing, Caching, Tests

💡 The Bottom Line

For most modern frontend and Node.js applications, fast-safe-stringify is the default winner. It prevents crashes without slowing down your app, making it perfect for logging and data transport where exact key order doesn't impact logic.

However, if your architecture relies on reproducibility—such as generating content hashes for caching, signing data, or running snapshot tests—you must use safe-stable-stringify. The slight performance cost is a necessary trade-off to ensure your system behaves predictably.

Avoid json-stringify-safe and safe-json-stringify for new projects. They represent an older generation of solutions that have been surpassed by faster, more robust alternatives. Stick to the actively maintained tools to keep your dependency tree healthy.

How to Choose: safe-stable-stringify vs fast-safe-stringify vs json-stringify-safe vs safe-json-stringify

  • safe-stable-stringify:

    Pick safe-stable-stringify when you need guaranteed deterministic output (stable key ordering) alongside circular reference safety. This is essential for generating consistent hashes, comparing objects in tests, or caching mechanisms where the string representation must remain identical across different runs, even if object key insertion order varies.

  • fast-safe-stringify:

    Choose fast-safe-stringify for general-purpose applications where performance is the top priority and key order does not matter. It is the fastest option for large datasets and is actively maintained, making it the default choice for high-throughput logging or API responses where deterministic output is not required.

  • json-stringify-safe:

    Avoid json-stringify-safe for new projects. While it was the original solution for circular references, it is no longer actively maintained and is significantly slower than modern alternatives. Only use it if you are maintaining a legacy codebase that already depends on it and cannot tolerate a refactor.

  • safe-json-stringify:

    Select safe-json-stringify only if you are working in a very specific legacy environment or need a extremely minimal footprint without modern optimizations. Like json-stringify-safe, it lacks the performance benefits and deterministic features of newer packages and is generally not recommended for modern architecture.

README for safe-stable-stringify

safe-stable-stringify

Safe, deterministic and fast serialization alternative to JSON.stringify. Zero dependencies. ESM and CJS. 100% coverage.

Gracefully handles circular structures and bigint instead of throwing.

Optional custom circular values, deterministic behavior or strict JSON compatibility check.

stringify(value[, replacer[, space]])

The same as JSON.stringify.

  • value {any}
  • replacer {string[]|function|null}
  • space {number|string}
  • Returns: {string}
const stringify = require('safe-stable-stringify')

const bigint = { a: 0, c: 2n, b: 1 }

stringify(bigint)
// '{"a":0,"b":1,"c":2}'
JSON.stringify(bigint)
// TypeError: Do not know how to serialize a BigInt

const circular = { b: 1, a: 0 }
circular.circular = circular

stringify(circular)
// '{"a":0,"b":1,"circular":"[Circular]"}'
JSON.stringify(circular)
// TypeError: Converting circular structure to JSON

stringify(circular, ['a', 'b'], 2)
// {
//   "a": 0,
//   "b": 1
// }

stringify.configure(options)

  • bigint {boolean} If true, bigint values are converted to a number. Otherwise they are ignored. Default: true.
  • circularValue {string|null|undefined|ErrorConstructor} Defines the value for circular references. Set to undefined, circular properties are not serialized (array entries are replaced with null). Set to Error, to throw on circular references. Default: '[Circular]'.
  • deterministic {boolean|function} If true or a Array#sort(comparator) comparator method, guarantee a deterministic key order instead of relying on the insertion order. Default: true.
  • maximumBreadth {number} Maximum number of entries to serialize per object (at least one). The serialized output contains information about how many entries have not been serialized. Ignored properties are counted as well (e.g., properties with symbol values). Using the array replacer overrules this option. Default: Infinity
  • maximumDepth {number} Maximum number of object nesting levels (at least 1) that will be serialized. Objects at the maximum level are serialized as '[Object]' and arrays as '[Array]'. Default: Infinity
  • strict {boolean} Instead of handling any JSON value gracefully, throw an error in case it may not be represented as JSON (functions, NaN, ...). Circular values and bigint values throw as well in case either option is not explicitly defined. Sets and Maps are not detected as well as Symbol keys! Default: false
  • Returns: {function} A stringify function with the options applied.
import { configure } from 'safe-stable-stringify'

const stringify = configure({
  bigint: true,
  circularValue: 'Magic circle!',
  deterministic: false,
  maximumDepth: 1,
  maximumBreadth: 4
})

const circular = {
  bigint: 999_999_999_999_999_999n,
  typed: new Uint8Array(3),
  deterministic: "I don't think so",
}
circular.circular = circular
circular.ignored = true
circular.alsoIgnored = 'Yes!'

const stringified = stringify(circular, null, 4)

console.log(stringified)
// {
//     "bigint": 999999999999999999,
//     "typed": "[Object]",
//     "deterministic": "I don't think so",
//     "circular": "Magic circle!",
//     "...": "2 items not stringified"
// }

const throwOnCircular = configure({
  circularValue: Error
})

throwOnCircular(circular);
// TypeError: Converting circular structure to JSON

Differences to JSON.stringify

  1. Circular values are replaced with the string [Circular] (configurable).
  2. Object keys are sorted instead of using the insertion order (configurable).
  3. BigInt values are stringified as regular number instead of throwing a TypeError (configurable).
  4. Boxed primitives (e.g., Number(5)) are not unboxed and are handled as regular object.

Those are the only differences to JSON.stringify(). This is a side effect free variant and toJSON, replacer and the spacer work the same as with JSON.stringify().

Performance / Benchmarks

Currently this is by far the fastest known stable (deterministic) stringify implementation. This is especially important for big objects and TypedArrays.

(Dell Precision 5540, i7-9850H CPU @ 2.60GHz, Node.js 16.11.1)

simple:   simple object x 3,463,894 ops/sec ±0.44% (98 runs sampled)
simple:   circular      x 1,236,007 ops/sec ±0.46% (99 runs sampled)
simple:   deep          x 18,942 ops/sec ±0.41% (93 runs sampled)
simple:   deep circular x 18,690 ops/sec ±0.72% (96 runs sampled)

replacer:   simple object x 2,664,940 ops/sec ±0.31% (98 runs sampled)
replacer:   circular      x 1,015,981 ops/sec ±0.09% (99 runs sampled)
replacer:   deep          x 17,328 ops/sec ±0.38% (97 runs sampled)
replacer:   deep circular x 17,071 ops/sec ±0.21% (98 runs sampled)

array:   simple object x 3,869,608 ops/sec ±0.22% (98 runs sampled)
array:   circular      x 3,853,943 ops/sec ±0.45% (96 runs sampled)
array:   deep          x 3,563,227 ops/sec ±0.20% (100 runs sampled)
array:   deep circular x 3,286,475 ops/sec ±0.07% (100 runs sampled)

indentation:   simple object x 2,183,162 ops/sec ±0.66% (97 runs sampled)
indentation:   circular      x 872,538 ops/sec ±0.57% (98 runs sampled)
indentation:   deep          x 16,795 ops/sec ±0.48% (93 runs sampled)
indentation:   deep circular x 16,443 ops/sec ±0.40% (97 runs sampled)

Comparing safe-stable-stringify with known alternatives:

fast-json-stable-stringify x 18,765 ops/sec ±0.71% (94 runs sampled)
json-stable-stringify x 13,870 ops/sec ±0.72% (94 runs sampled)
fast-stable-stringify x 21,343 ops/sec ±0.33% (95 runs sampled)
faster-stable-stringify x 17,707 ops/sec ±0.44% (97 runs sampled)
json-stringify-deterministic x 11,208 ops/sec ±0.57% (98 runs sampled)
fast-safe-stringify x 21,460 ops/sec ±0.75% (99 runs sampled)
this x 30,367 ops/sec ±0.39% (96 runs sampled)

The fastest is this

The fast-safe-stringify comparison uses the modules stable implementation.

Acknowledgements

Sponsored by MaibornWolff and nearForm

License

MIT