Skip to content

Performance: Redundant deep copy in parse()deserialize() flow accounts for ~80% of deserialization time #319

@shkreios

Description

@shkreios

Hello! First, thank you for maintaining SuperJSON, i am using it every single project.

Issue Description

I've been investigating performance while deserializing large datasets and noticed that when using SuperJSON.parse(), there's a redundant deep copy operation that significantly impacts performance.

Technical Analysis

Looking at the current implementation:

  1. parse() method (src/index.ts:84) calls JSON.parse(string) which creates a brand new object
  2. This new object is then passed to deserialize()
  3. deserialize() (src/index.ts:64) immediately calls copy(json) to create another deep copy to not mutate the input itself

The flow is:

parse(string) → JSON.parse(string) → new object → deserialize(new object) → copy(new object)

Since JSON.parse() already creates a completely new object with no external references, the subsequent copy() operation is redundant in this specific flow.

Performance Impact

In our benchmarks with large datasets (arrays of complex objects), the copy() operation accounts for approximately 80% of the total deserialization time. This is particularly noticeable when deserializing arrays with thousands of items. I can go in more details in the specific case i am running into if needed.

Why the Copy Exists

I understand the copy is necessary when deserialize() is called directly with an existing object to prevent mutations:

const payload = { json: existingObject, meta: {...} };
const result = SuperJSON.deserialize(payload);
// Without copy, existingObject would be mutated by applyValueAnnotations/applyReferentialEqualityAnnotations

Proposed Solution

Would you consider adding an optional flag to deserialize() to skip the copy when it's safe to do so?

deserialize<T = unknown>(payload: SuperJSONResult, options?: { skipCopy?: boolean }): T {
  const { json, meta } = payload;
  
  let result: T = options?.skipCopy ? json : copy(json) as any;
  
  if (meta?.values) {
    result = applyValueAnnotations(result, meta.values, this);
  }
  // ... rest of the method
}

parse<T = unknown>(string: string): T {
  return this.deserialize(JSON.parse(string), { skipCopy: true });
}

This would:

  • Maintain backward compatibility (default behavior unchanged)
  • Allow parse() to skip the redundant copy
  • Let advanced users optimize performance when they know the input is safe to mutate

Alternative Approaches

If adding a parameter isn't desirable, an internal method like _deserializeInPlace() could work, though the flag approach seems cleaner and could benefit other use cases where users know their input is safe to mutate.

Thank you for considering this optimization. I'd be happy to submit a PR if you think this approach makes sense!

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions