Skip to content

Commit d60da71

Browse files
committed
Improve readme, add benchmark and results
1 parent 8001f4f commit d60da71

6 files changed

Lines changed: 1138 additions & 684 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.direnv
12
node_modules
23
dist
34
tsconfig.tsbuildinfo

Readme.md

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,57 @@
11
# `DeferredJSON` ![test](https://github.com/gadget-inc/deferredjson/actions/workflows/test.yml/badge.svg?branch=main&event=push) ![bundle size](https://img.shields.io/bundlephobia/min/deferredjson)
22

3-
`DeferredJSON` offers a drop in replacement for `JSON.stringify` and `JSON.parse` that defers parsing until the data is actually needed. If you happen to serialize a DeferredJSON instance later to a string without ever touching it, than neither the parse nor stringify needs to happen, and DeferredJSON can just feed out the already serialized contents stored at the start.
3+
`DeferredJSON` offers a drop in replacement for `JSON.stringify` and `JSON.parse` that defers parsing until the parsed result is actually needed. If you re-stringify without ever accessing the data, the parsing can be fully avoided and performance greatly improved.
44

5-
`DeferredJSON` works by returning a `Proxy` object that parses on demand the first time a value is accessed. `DeferredJSON` works with any incoming JSON value, including serialized arrays, objects, and primitive values.
5+
`DeferredJSON` works by returning a `Proxy` object that parses on demand the first time a value is accessed, and storing the incoming serialized string of JSON for later use. Then later when `DeferredJSON.stringify` is used, any deferreds within the serialized tree re-use that incoming serialized string, avoiding the cost of re-serializing.
6+
7+
`DeferredJSON` works with any incoming JSON value, including serialized arrays, objects, and primitive values.
68

79
Also handy is that `DeferredJSON` serialization can interpolate several nested lazy JSON objects into an outer one when stringifing. If you are returning an outer object (say a REST API response) where one field on each record is a potentially-large JSON object, you can use `DeferredJSON` for those large objects, but still rely on `DeferredJSON.stringify` to avoid the cost of deserializing-and-re-serializing each little object in the payload.
810

911
## Background
1012

1113
A lot of nodejs programs serve up JSON data to a client, and to do that, some in-memory datastructure has to get serialized. Usually, you have to pay the price of serializing an entire JSON tree, which is an [event-loop-blocking](https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop) operation. If the JSON is very large, this event loop block can become a major issue.
1214

13-
But, a lot of the time, the JSON a program is serving is available in an _already serialized JSON_ form. It could be bytes in a file, or a `json` or `jsonb` field in Postgres, or incoming bytes from a request. If you already have JSON that you trust is valid in string form, it is wasteful to parse it, never touch it, and then serialize it again.
15+
But a lot of the time, programs are serving JSON that started its life in an _already serialized string_ form. It could be bytes in a file, or a `json` or `jsonb` field in Postgres, or incoming bytes from a request. If you already have a JSON string that you trust is valid, it is wasteful to parse it, never touch it, and then serialize it again.
16+
17+
`DeferredJSON` helps with this performance issue in this specific situation by deferring the parsing of your existing serialized JSON until the last possible moment. If you do need to access data within the JSON, `DeferredJSON` will parse it on demand, incurring the same performance penalty as a normal `JSON.parse`. But, if you never end up accessing the deserialized data, `DeferredJSON` will never parse it, and can feed out the already serialized contents as is.
18+
19+
### Performance
20+
21+
`DeferredJSON` is written with high-performance node.js apps in mind and does its best to add as little overhead as possible, but there is some. When accessing keys of a `DeferredJSON` object, there is a small amount of overhead added to go through the proxy for each property access at the root-level node that is parsed. If you need absolutely no overhead in accessing the data you are parsing, then don't use `DeferredJSON`.
22+
23+
For serialization, `DeferredJSON` also adds some small overhead. `DeferredJSON` still uses the JS VM's high-performance `JSON.stringify` under the hood take advantage of all the optimizations baked in there, but then does a second pass over the serialized string to interpolate if needed. This adds some overhead, but for JSON objects of any size, the performance is still much better than doing the whole parse and re-serializing of the objects in question.
24+
25+
`DeferredJSON` was extracted out of [Gadget](https://gadget.dev) where it made a major performance difference for the JSON responses Gadget serves up.
1426

15-
`DeferredJSON` helps with this performance issue in this specific situation by deferring the parsing of your existing serialized JSON until the last possible moment. If you do need to access data within the JSON, `DeferredJSON` will parse it on demand. But, if you never need to access the data, `DeferredJSON` will never parse it, and can feed out the already serialized contents as is.
27+
Here's the results of the benchmark at `spec/deferredjson.bench.ts`:
28+
29+
| Task Name | ops/sec | Average Time (ns) | Margin | Samples |
30+
| ------------------------------------------------------------------------------------ | --------- | ------------------ | ------ | ------- |
31+
| JSON stringifying after no touching | 4,257 | 234867.73648732144 | ±0.61% | 426 |
32+
| JSON stringifying after touching | 4,037 | 247650.98478534434 | ±1.47% | 404 |
33+
| JSON stringifying outer object containing several child parses | 873,536 | 1144.7716234727998 | ±0.53% | 87354 |
34+
| JSON stringifying outer object containing several large child parses | 1,371 | 729038.3482324905 | ±0.98% | 138 |
35+
| JSON stringifying outer object containing several large touched child parses | 1,452 | 688475.1752631305 | ±0.77% | 146 |
36+
| JSON iterating all keys | 7,599 | 131594.6274682095 | ±0.61% | 760 |
37+
| DeferredJSON stringifying after no touching | 1,128,805 | 885.89238811691 | ±0.73% | 112881 |
38+
| DeferredJSON stringifying after touching | 8,643 | 115694.12126706514 | ±3.30% | 865 |
39+
| DeferredJSON stringifying outer object containing several child parses | 431,193 | 2319.1457362033443 | ±0.74% | 43120 |
40+
| DeferredJSON stringifying outer object containing several large child parses | 226,622 | 4412.6266394405075 | ±1.81% | 22663 |
41+
| DeferredJSON stringifying outer object containing several large touched child parses | 4,110 | 243299.04503035315 | ±1.08% | 412 |
42+
| DeferredJSON iterating all keys | 1,788 | 559064.4635301728 | ±0.75% | 179 |
1643

1744
## Usage
1845

1946
### Parsing
2047

21-
Use `DeferredJSON.parse` instead of `JSON.parse`, that's all. `DeferredJSON.parse` will return a `Proxy` object that should act just like a normal JSON object.
48+
Use `DeferredJSON.parse` instead of `JSON.parse` -- that's all. `DeferredJSON.parse` will return a `Proxy` object that acts just like a normal JSON object.
2249

2350
```typescript
2451
const obj = DeferredJSON.parse(`{"foo": "bar"}`);
2552
// no parsing has happened yet
26-
obj.foo; // "bar"
53+
54+
obj.foo; // triggers parsing of the string and returns "bar"
2755
// object is now parsed and quacks the same as if JSON.parse was used
2856
```
2957

@@ -38,8 +66,9 @@ types.setTypeParser(types.builtins.JSONB, DeferredJSON.parse);
3866

3967
There's a few optimizations `DeferredJSON.parse` makes to be aware of:
4068

41-
- if the serialized JSON is a scalar value like a number or a boolean, it isn't wrapped in a `DeferredJSON` proxy, since parsing it is so cheap.
42-
- if the JSON is still unparsed, and it is awaited by accessing the `.then` property, the JSON won't be deserialized, and instead the whole JSON will be returned. This means that if the `.then` property is actually a string inside the JSON that you care about, you need to forcibly parse the JSON to access it. This is a good thing, since it means you can still blindly await your `DeferredJSON`s and not worry about eagerly forcing parsing for no reason other than some async function baloney.
69+
- unlike `JSON.parse`, if the string is invalid JSON, it won't throw an error until the first time the inner JSON is accessed.
70+
- if the serialized JSON is a scalar value like a number or a boolean, it isn't wrapped in a `DeferredJSON` proxy and instead is eagerly deserialized and returned. Benchmarks showed proxying these simple scalars not to be worth it since parsing it is so cheap.
71+
- if the JSON is still unparsed, and it is awaited by accessing the `.then` property, the JSON **won't** be deserialized. Instead the whole JSON will be returned. This means that if the `.then` property is actually a string inside the JSON that you care about, you need to forcibly parse the JSON to access it. This is a good thing, since it means you can still blindly await your `DeferredJSON`s and not worry about eagerly forcing parsing for no reason other than some async function baloney.
4372

4473
### Serializing
4574

@@ -62,11 +91,3 @@ const response = {
6291
DeferredJSON.stringify(response);
6392
// `{"data":{"foo":"bar"},"other":"stuff"}`
6493
```
65-
66-
### Performance
67-
68-
`DeferredJSON` is written with high-performance node.js apps in mind and does its best to add as little overhead as possible, but there is some. When accessing keys of a `DeferredJSON` object, there is a small amount of overhead added to go through the proxy for each property access at the root-level node that is parsed. If you need absolutely no overhead in accessing the data you are parsing, then don't use `DeferredJSON`.
69-
70-
For serialization, `DeferredJSON` also adds some small overhead. `DeferredJSON` still uses the JS VM's `JSON.stringify` under the hood to get maximum performance and all the optimizations baked in there, but then does a second pass over the serialized string to interpolate if needed. This adds some overhead, but for JSON objects of any size, the performance is still much better than doing the whole parse and re-serializing of the objects in question.
71-
72-
`DeferredJSON` was extracted out of [Gadget](https://gadget.dev) where it made a major performance difference for the JSON responses Gadget serves up.

flake.lock

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"lint:prettier": "NODE_OPTIONS=\"--max-old-space-size=4096\" prettier --check \"./**/*.{js,ts,tsx}\"",
3030
"lint:eslint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint --quiet --ext ts,tsx .",
3131
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" prettier --write --check \"./**/*.{js,ts,tsx}\" && eslint --ext ts,tsx --fix .",
32+
"x": "tsx",
3233
"prepublishOnly": "pnpm build",
3334
"prerelease": "gitpkg publish"
3435
},
@@ -45,11 +46,15 @@
4546
"eslint": "^8.57.0",
4647
"execa": "^9.3.0",
4748
"jest": "^29.7.0",
49+
"markdown-table": "^3.0.3",
4850
"msgpackr": "^1.11.0",
4951
"prettier": "^2.8.8",
52+
"tinybench": "^2.8.0",
53+
"tsx": "^4.16.2",
5054
"typescript": "5.4.5"
5155
},
5256
"dependencies": {
5357
"lodash-es": "^4.17.21"
54-
}
58+
},
59+
"packageManager": "pnpm@8.12.0+sha256.553e4eb0e2a2c9abcb419b3262bdc7aee8ae3c42e2301a1807d44575786160c9"
5560
}

0 commit comments

Comments
 (0)