Commit f247eba

Browse files
[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary Follow-up to vercel/next.js#89823 with the actual changes to React. Replaces the `JSON.parse` reviver callback in `initializeModelChunk` with a two-step approach: plain `JSON.parse()` followed by a recursive `reviveModel()` post-process (same as in Flight Reply Server). This yields a **~75% speedup** in RSC chunk deserialization. | Payload | Original (ms) | Walk (ms) | Speedup | |---------|---------------|-----------|---------| | Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** | | Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** | | Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** | | XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** | | Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** | ## Problem `createFromJSONCallback` returns a reviver function passed as the second argument to `JSON.parse()`. This reviver is called for **every key-value pair** in the parsed JSON. While the logic inside the reviver is lightweight, the dominant cost is the **C++ → JavaScript boundary crossing** — V8's `JSON.parse` is implemented in C++, and calling back into JavaScript for every node incurs significant overhead. Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x slower** than bare `JSON.parse` without a reviver: ``` 108 KB payload: Bare JSON.parse: 0.60 ms Trivial reviver: 2.95 ms (+391%) ``` ## Change Replace the reviver with a two-step process: 1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no callbacks 2. `reviveModel` — recursively walk the resulting object in pure JavaScript to apply RSC transformations The `reviveModel` function includes additional optimizations over the original reviver: - **Short-circuits plain strings**: only calls `parseModelString` when the string starts with `$`, skipping the vast majority of strings (class names, text content, etc.) - **Stays entirely in JavaScript** — no C++ boundary crossings during the walk ## Results You can find the related applications in the [Next.js PR ](vercel/next.js#89823 I've been testing this on Next.js applications. ### Table as Server Component with 1000 items Before: ``` "min": 13.782875000000786, "max": 22.23400000000038, "avg": 17.116868530000083, "p50": 17.10766700000022, "p75": 18.50787499999933, "p95": 20.426249999998618, "p99": 21.814125000000786 ``` After: ``` "min": 10.963916999999128, "max": 18.096083000000363, "avg": 13.543286884999988, "p50": 13.58350000000064, "p75": 14.871791999999914, "p95": 16.08429099999921, "p99": 17.591458000000785 ``` ### Table as Client Component with 1000 items Before: ``` "min": 3.888875000000553, "max": 9.044959000000745, "avg": 4.651271475000067, "p50": 4.555749999999534, "p75": 4.966624999999112, "p95": 5.47754200000054, "p99": 6.109499999998661 ```` After: ``` "min": 3.5986250000005384, "max": 5.374291000000085, "avg": 4.142990245000046, "p50": 4.10570799999914, "p75": 4.392041999999492, "p95": 4.740084000000934, "p99": 5.1652500000000146 ``` ### Nested Suspense Before: ``` Requests: 200 Min: 73ms Max: 106ms Avg: 78ms P50: 77ms P75: 80ms P95: 85ms P99: 94ms ``` After: ``` Requests: 200 Min: 56ms Max: 67ms Avg: 59ms P50: 58ms P75: 60ms P95: 65ms P99: 66ms ``` ### Even more nested Suspense (double-level Suspense) Before: ``` Requests: 200 Min: 159ms Max: 208ms Avg: 169ms P50: 167ms P75: 173ms P95: 183ms P99: 188ms ``` After: ``` Requests: 200 Min: 125ms Max: 170ms Avg: 134ms P50: 132ms P75: 138ms P95: 148ms P99: 160ms ``` ## How did you test this change? Ran it across many Next.js benchmark applications. The entire Next.js test suite passes with this change. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 3a2bee2 commit f247eba

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,6 @@ type Response = {
355355
_encodeFormAction: void|EncodeFormActionCallback,
356356
_nonce: ?string,
357357
_chunks: Map<number, SomeChunk<any>>,
358-
_fromJSON: (key: string,value: JSONValue)=>any,
359358
_stringDecoder: StringDecoder,
360359
_closed: boolean,
361360
_closedReason: mixed,
@@ -2302,6 +2301,11 @@ function defineLazyGetter<T>(
23022301
// TODO: We should ideally throw here to indicate a difference.
23032302
returnOMITTED_PROP_ERROR;
23042303
},
2304+
// no-op: the walk function may try to reassign this property after
2305+
// parseModelString returns. With the JSON.parse reviver, the engine's
2306+
// internal CreateDataProperty silently failed. We use a no-op setter
2307+
// to match that behavior in strict mode.
2308+
set: function(){},
23052309
enumerable: true,
23062310
configurable: false,
23072311
});
@@ -2622,6 +2626,11 @@ function parseModelString(
26222626
// TODO: We should ideally throw here to indicate a difference.
26232627
returnOMITTED_PROP_ERROR;
26242628
},
2629+
// no-op: the walk function may try to reassign this property
2630+
// after parseModelString returns. With the JSON.parse reviver,
2631+
// the engine's internal CreateDataProperty silently failed.
2632+
// We use a no-op setter to match that behavior in strict mode.
2633+
set: function(){},
26252634
enumerable: true,
26262635
configurable: false,
26272636
});
@@ -2699,7 +2708,6 @@ function ResponseInstance(
26992708
this._nonce=nonce;
27002709
this._chunks=chunks;
27012710
this._stringDecoder=createStringDecoder();
2702-
this._fromJSON=(null: any);
27032711
this._closed=false;
27042712
this._closedReason=null;
27052713
this._allowPartialStream=allowPartialStream;
@@ -2783,9 +2791,6 @@ function ResponseInstance(
27832791
markAllTracksInOrder();
27842792
}
27852793
}
2786-
2787-
// Don't inline this call because it causes closure to outline the call above.
2788-
this._fromJSON=createFromJSONCallback(this);
27892794
}
27902795

27912796
exportfunctioncreateResponse(
@@ -5259,24 +5264,52 @@ export function processStringChunk(
52595264
}
52605265

52615266
functionparseModel<T>(response: Response, json: UninitializedModel): T {
5262-
returnJSON.parse(json,response._fromJSON);
5267+
constrawModel=JSON.parse(json);
5268+
// Pass a wrapper object as parentObject to match the original JSON.parse
5269+
// reviver behavior, where the root value's reviver receives {"": rootValue}
5270+
// as `this`. This ensures parentObject is never null when accessed downstream.
5271+
returnreviveModel(response,rawModel,{'': rawModel},'');
52635272
}
52645273

5265-
function createFromJSONCallback(response: Response) {
5266-
// $FlowFixMe[missing-this-annot]
5267-
returnfunction(key: string,value: JSONValue){
5268-
if(key===__PROTO__){
5269-
returnundefined;
5274+
function reviveModel(
5275+
response: Response,
5276+
value: JSONValue,
5277+
parentObject: Object,
5278+
key: string,
5279+
): any {
5280+
if(typeofvalue==='string'){
5281+
if(value[0]==='$'){
5282+
returnparseModelString(response,parentObject,key,value);
52705283
}
5271-
if(typeofvalue==='string'){
5272-
// We can't use .bind here because we need the "this" value.
5273-
returnparseModelString(response,this,key,value);
5284+
return value;
5285+
}
5286+
if(typeofvalue!== 'object' ||value===null){
5287+
returnvalue;
5288+
}
5289+
if (isArray(value)) {
5290+
for(leti=0;i<value.length;i++){
5291+
(value: any)[i]=reviveModel(response,value[i],value,''+i);
52745292
}
5275-
if(typeofvalue==='object'&&value!==null){
5293+
if (value[0] === REACT_ELEMENT_TYPE) {
5294+
// React element tuple
52765295
returnparseModelTuple(response,value);
52775296
}
52785297
return value;
5279-
};
5298+
}
5299+
// Plain object
5300+
for(constkinvalue){
5301+
if(k===__PROTO__){
5302+
delete(value: any)[k];
5303+
} else {
5304+
constwalked=reviveModel(response,(value: any)[k],value,k);
5305+
if(walked!==undefined){
5306+
(value: any)[k]=walked;
5307+
} else {
5308+
delete(value: any)[k];
5309+
}
5310+
}
5311+
}
5312+
returnvalue;
52805313
}
52815314

52825315
exportfunctionclose(weakResponse: WeakResponse): void{

‎packages/react-noop-renderer/src/ReactNoopFlightClient.js‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} =
4343
requireModule(idx: string){
4444
returnreadModule(idx);
4545
},
46-
parseModel(response: Response,json){
47-
returnJSON.parse(json,response._fromJSON);
48-
},
4946
bindToConsole(methodName,args,badgeName){
5047
returnFunction.prototype.bind.apply(
5148
// eslint-disable-next-line react-internal/no-production-logging

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit f247eba

Browse files
[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary Follow-up to vercel/next.js#89823 with the actual changes to React. Replaces the `JSON.parse` reviver callback in `initializeModelChunk` with a two-step approach: plain `JSON.parse()` followed by a recursive `reviveModel()` post-process (same as in Flight Reply Server). This yields a **~75% speedup** in RSC chunk deserialization. | Payload | Original (ms) | Walk (ms) | Speedup | |---------|---------------|-----------|---------| | Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** | | Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** | | Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** | | XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** | | Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** | ## Problem `createFromJSONCallback` returns a reviver function passed as the second argument to `JSON.parse()`. This reviver is called for **every key-value pair** in the parsed JSON. While the logic inside the reviver is lightweight, the dominant cost is the **C++ → JavaScript boundary crossing** — V8's `JSON.parse` is implemented in C++, and calling back into JavaScript for every node incurs significant overhead. Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x slower** than bare `JSON.parse` without a reviver: ``` 108 KB payload: Bare JSON.parse: 0.60 ms Trivial reviver: 2.95 ms (+391%) ``` ## Change Replace the reviver with a two-step process: 1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no callbacks 2. `reviveModel` — recursively walk the resulting object in pure JavaScript to apply RSC transformations The `reviveModel` function includes additional optimizations over the original reviver: - **Short-circuits plain strings**: only calls `parseModelString` when the string starts with `$`, skipping the vast majority of strings (class names, text content, etc.) - **Stays entirely in JavaScript** — no C++ boundary crossings during the walk ## Results You can find the related applications in the [Next.js PR ](vercel/next.js#89823 I've been testing this on Next.js applications. ### Table as Server Component with 1000 items Before: ``` "min": 13.782875000000786, "max": 22.23400000000038, "avg": 17.116868530000083, "p50": 17.10766700000022, "p75": 18.50787499999933, "p95": 20.426249999998618, "p99": 21.814125000000786 ``` After: ``` "min": 10.963916999999128, "max": 18.096083000000363, "avg": 13.543286884999988, "p50": 13.58350000000064, "p75": 14.871791999999914, "p95": 16.08429099999921, "p99": 17.591458000000785 ``` ### Table as Client Component with 1000 items Before: ``` "min": 3.888875000000553, "max": 9.044959000000745, "avg": 4.651271475000067, "p50": 4.555749999999534, "p75": 4.966624999999112, "p95": 5.47754200000054, "p99": 6.109499999998661 ```` After: ``` "min": 3.5986250000005384, "max": 5.374291000000085, "avg": 4.142990245000046, "p50": 4.10570799999914, "p75": 4.392041999999492, "p95": 4.740084000000934, "p99": 5.1652500000000146 ``` ### Nested Suspense Before: ``` Requests: 200 Min: 73ms Max: 106ms Avg: 78ms P50: 77ms P75: 80ms P95: 85ms P99: 94ms ``` After: ``` Requests: 200 Min: 56ms Max: 67ms Avg: 59ms P50: 58ms P75: 60ms P95: 65ms P99: 66ms ``` ### Even more nested Suspense (double-level Suspense) Before: ``` Requests: 200 Min: 159ms Max: 208ms Avg: 169ms P50: 167ms P75: 173ms P95: 183ms P99: 188ms ``` After: ``` Requests: 200 Min: 125ms Max: 170ms Avg: 134ms P50: 132ms P75: 138ms P95: 148ms P99: 160ms ``` ## How did you test this change? Ran it across many Next.js benchmark applications. The entire Next.js test suite passes with this change. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 3a2bee2 commit f247eba

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,6 @@ type Response = {
355355
_encodeFormAction: void|EncodeFormActionCallback,
356356
_nonce: ?string,
357357
_chunks: Map<number, SomeChunk<any>>,
358-
_fromJSON: (key: string,value: JSONValue)=>any,
359358
_stringDecoder: StringDecoder,
360359
_closed: boolean,
361360
_closedReason: mixed,
@@ -2302,6 +2301,11 @@ function defineLazyGetter<T>(
23022301
// TODO: We should ideally throw here to indicate a difference.
23032302
returnOMITTED_PROP_ERROR;
23042303
},
2304+
// no-op: the walk function may try to reassign this property after
2305+
// parseModelString returns. With the JSON.parse reviver, the engine's
2306+
// internal CreateDataProperty silently failed. We use a no-op setter
2307+
// to match that behavior in strict mode.
2308+
set: function(){},
23052309
enumerable: true,
23062310
configurable: false,
23072311
});
@@ -2622,6 +2626,11 @@ function parseModelString(
26222626
// TODO: We should ideally throw here to indicate a difference.
26232627
returnOMITTED_PROP_ERROR;
26242628
},
2629+
// no-op: the walk function may try to reassign this property
2630+
// after parseModelString returns. With the JSON.parse reviver,
2631+
// the engine's internal CreateDataProperty silently failed.
2632+
// We use a no-op setter to match that behavior in strict mode.
2633+
set: function(){},
26252634
enumerable: true,
26262635
configurable: false,
26272636
});
@@ -2699,7 +2708,6 @@ function ResponseInstance(
26992708
this._nonce=nonce;
27002709
this._chunks=chunks;
27012710
this._stringDecoder=createStringDecoder();
2702-
this._fromJSON=(null: any);
27032711
this._closed=false;
27042712
this._closedReason=null;
27052713
this._allowPartialStream=allowPartialStream;
@@ -2783,9 +2791,6 @@ function ResponseInstance(
27832791
markAllTracksInOrder();
27842792
}
27852793
}
2786-
2787-
// Don't inline this call because it causes closure to outline the call above.
2788-
this._fromJSON=createFromJSONCallback(this);
27892794
}
27902795

27912796
exportfunctioncreateResponse(
@@ -5259,24 +5264,52 @@ export function processStringChunk(
52595264
}
52605265

52615266
functionparseModel<T>(response: Response, json: UninitializedModel): T {
5262-
returnJSON.parse(json,response._fromJSON);
5267+
constrawModel=JSON.parse(json);
5268+
// Pass a wrapper object as parentObject to match the original JSON.parse
5269+
// reviver behavior, where the root value's reviver receives {"": rootValue}
5270+
// as `this`. This ensures parentObject is never null when accessed downstream.
5271+
returnreviveModel(response,rawModel,{'': rawModel},'');
52635272
}
52645273

5265-
function createFromJSONCallback(response: Response) {
5266-
// $FlowFixMe[missing-this-annot]
5267-
returnfunction(key: string,value: JSONValue){
5268-
if(key===__PROTO__){
5269-
returnundefined;
5274+
function reviveModel(
5275+
response: Response,
5276+
value: JSONValue,
5277+
parentObject: Object,
5278+
key: string,
5279+
): any {
5280+
if(typeofvalue==='string'){
5281+
if(value[0]==='$'){
5282+
returnparseModelString(response,parentObject,key,value);
52705283
}
5271-
if(typeofvalue==='string'){
5272-
// We can't use .bind here because we need the "this" value.
5273-
returnparseModelString(response,this,key,value);
5284+
return value;
5285+
}
5286+
if(typeofvalue!== 'object' ||value===null){
5287+
returnvalue;
5288+
}
5289+
if (isArray(value)) {
5290+
for(leti=0;i<value.length;i++){
5291+
(value: any)[i]=reviveModel(response,value[i],value,''+i);
52745292
}
5275-
if(typeofvalue==='object'&&value!==null){
5293+
if (value[0] === REACT_ELEMENT_TYPE) {
5294+
// React element tuple
52765295
returnparseModelTuple(response,value);
52775296
}
52785297
return value;
5279-
};
5298+
}
5299+
// Plain object
5300+
for(constkinvalue){
5301+
if(k===__PROTO__){
5302+
delete(value: any)[k];
5303+
} else {
5304+
constwalked=reviveModel(response,(value: any)[k],value,k);
5305+
if(walked!==undefined){
5306+
(value: any)[k]=walked;
5307+
} else {
5308+
delete(value: any)[k];
5309+
}
5310+
}
5311+
}
5312+
returnvalue;
52805313
}
52815314

52825315
exportfunctionclose(weakResponse: WeakResponse): void{

‎packages/react-noop-renderer/src/ReactNoopFlightClient.js‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} =
4343
requireModule(idx: string){
4444
returnreadModule(idx);
4545
},
46-
parseModel(response: Response,json){
47-
returnJSON.parse(json,response._fromJSON);
48-
},
4946
bindToConsole(methodName,args,badgeName){
5047
returnFunction.prototype.bind.apply(
5148
// eslint-disable-next-line react-internal/no-production-logging

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit f247eba

Browse files
[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary Follow-up to vercel/next.js#89823 with the actual changes to React. Replaces the `JSON.parse` reviver callback in `initializeModelChunk` with a two-step approach: plain `JSON.parse()` followed by a recursive `reviveModel()` post-process (same as in Flight Reply Server). This yields a **~75% speedup** in RSC chunk deserialization. | Payload | Original (ms) | Walk (ms) | Speedup | |---------|---------------|-----------|---------| | Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** | | Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** | | Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** | | XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** | | Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** | ## Problem `createFromJSONCallback` returns a reviver function passed as the second argument to `JSON.parse()`. This reviver is called for **every key-value pair** in the parsed JSON. While the logic inside the reviver is lightweight, the dominant cost is the **C++ → JavaScript boundary crossing** — V8's `JSON.parse` is implemented in C++, and calling back into JavaScript for every node incurs significant overhead. Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x slower** than bare `JSON.parse` without a reviver: ``` 108 KB payload: Bare JSON.parse: 0.60 ms Trivial reviver: 2.95 ms (+391%) ``` ## Change Replace the reviver with a two-step process: 1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no callbacks 2. `reviveModel` — recursively walk the resulting object in pure JavaScript to apply RSC transformations The `reviveModel` function includes additional optimizations over the original reviver: - **Short-circuits plain strings**: only calls `parseModelString` when the string starts with `$`, skipping the vast majority of strings (class names, text content, etc.) - **Stays entirely in JavaScript** — no C++ boundary crossings during the walk ## Results You can find the related applications in the [Next.js PR ](vercel/next.js#89823 I've been testing this on Next.js applications. ### Table as Server Component with 1000 items Before: ``` "min": 13.782875000000786, "max": 22.23400000000038, "avg": 17.116868530000083, "p50": 17.10766700000022, "p75": 18.50787499999933, "p95": 20.426249999998618, "p99": 21.814125000000786 ``` After: ``` "min": 10.963916999999128, "max": 18.096083000000363, "avg": 13.543286884999988, "p50": 13.58350000000064, "p75": 14.871791999999914, "p95": 16.08429099999921, "p99": 17.591458000000785 ``` ### Table as Client Component with 1000 items Before: ``` "min": 3.888875000000553, "max": 9.044959000000745, "avg": 4.651271475000067, "p50": 4.555749999999534, "p75": 4.966624999999112, "p95": 5.47754200000054, "p99": 6.109499999998661 ```` After: ``` "min": 3.5986250000005384, "max": 5.374291000000085, "avg": 4.142990245000046, "p50": 4.10570799999914, "p75": 4.392041999999492, "p95": 4.740084000000934, "p99": 5.1652500000000146 ``` ### Nested Suspense Before: ``` Requests: 200 Min: 73ms Max: 106ms Avg: 78ms P50: 77ms P75: 80ms P95: 85ms P99: 94ms ``` After: ``` Requests: 200 Min: 56ms Max: 67ms Avg: 59ms P50: 58ms P75: 60ms P95: 65ms P99: 66ms ``` ### Even more nested Suspense (double-level Suspense) Before: ``` Requests: 200 Min: 159ms Max: 208ms Avg: 169ms P50: 167ms P75: 173ms P95: 183ms P99: 188ms ``` After: ``` Requests: 200 Min: 125ms Max: 170ms Avg: 134ms P50: 132ms P75: 138ms P95: 148ms P99: 160ms ``` ## How did you test this change? Ran it across many Next.js benchmark applications. The entire Next.js test suite passes with this change. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 3a2bee2 commit f247eba

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,6 @@ type Response = {
355355
_encodeFormAction: void|EncodeFormActionCallback,
356356
_nonce: ?string,
357357
_chunks: Map<number, SomeChunk<any>>,
358-
_fromJSON: (key: string,value: JSONValue)=>any,
359358
_stringDecoder: StringDecoder,
360359
_closed: boolean,
361360
_closedReason: mixed,
@@ -2302,6 +2301,11 @@ function defineLazyGetter<T>(
23022301
// TODO: We should ideally throw here to indicate a difference.
23032302
returnOMITTED_PROP_ERROR;
23042303
},
2304+
// no-op: the walk function may try to reassign this property after
2305+
// parseModelString returns. With the JSON.parse reviver, the engine's
2306+
// internal CreateDataProperty silently failed. We use a no-op setter
2307+
// to match that behavior in strict mode.
2308+
set: function(){},
23052309
enumerable: true,
23062310
configurable: false,
23072311
});
@@ -2622,6 +2626,11 @@ function parseModelString(
26222626
// TODO: We should ideally throw here to indicate a difference.
26232627
returnOMITTED_PROP_ERROR;
26242628
},
2629+
// no-op: the walk function may try to reassign this property
2630+
// after parseModelString returns. With the JSON.parse reviver,
2631+
// the engine's internal CreateDataProperty silently failed.
2632+
// We use a no-op setter to match that behavior in strict mode.
2633+
set: function(){},
26252634
enumerable: true,
26262635
configurable: false,
26272636
});
@@ -2699,7 +2708,6 @@ function ResponseInstance(
26992708
this._nonce=nonce;
27002709
this._chunks=chunks;
27012710
this._stringDecoder=createStringDecoder();
2702-
this._fromJSON=(null: any);
27032711
this._closed=false;
27042712
this._closedReason=null;
27052713
this._allowPartialStream=allowPartialStream;
@@ -2783,9 +2791,6 @@ function ResponseInstance(
27832791
markAllTracksInOrder();
27842792
}
27852793
}
2786-
2787-
// Don't inline this call because it causes closure to outline the call above.
2788-
this._fromJSON=createFromJSONCallback(this);
27892794
}
27902795

27912796
exportfunctioncreateResponse(
@@ -5259,24 +5264,52 @@ export function processStringChunk(
52595264
}
52605265

52615266
functionparseModel<T>(response: Response, json: UninitializedModel): T {
5262-
returnJSON.parse(json,response._fromJSON);
5267+
constrawModel=JSON.parse(json);
5268+
// Pass a wrapper object as parentObject to match the original JSON.parse
5269+
// reviver behavior, where the root value's reviver receives {"": rootValue}
5270+
// as `this`. This ensures parentObject is never null when accessed downstream.
5271+
returnreviveModel(response,rawModel,{'': rawModel},'');
52635272
}
52645273

5265-
function createFromJSONCallback(response: Response) {
5266-
// $FlowFixMe[missing-this-annot]
5267-
returnfunction(key: string,value: JSONValue){
5268-
if(key===__PROTO__){
5269-
returnundefined;
5274+
function reviveModel(
5275+
response: Response,
5276+
value: JSONValue,
5277+
parentObject: Object,
5278+
key: string,
5279+
): any {
5280+
if(typeofvalue==='string'){
5281+
if(value[0]==='$'){
5282+
returnparseModelString(response,parentObject,key,value);
52705283
}
5271-
if(typeofvalue==='string'){
5272-
// We can't use .bind here because we need the "this" value.
5273-
returnparseModelString(response,this,key,value);
5284+
return value;
5285+
}
5286+
if(typeofvalue!== 'object' ||value===null){
5287+
returnvalue;
5288+
}
5289+
if (isArray(value)) {
5290+
for(leti=0;i<value.length;i++){
5291+
(value: any)[i]=reviveModel(response,value[i],value,''+i);
52745292
}
5275-
if(typeofvalue==='object'&&value!==null){
5293+
if (value[0] === REACT_ELEMENT_TYPE) {
5294+
// React element tuple
52765295
returnparseModelTuple(response,value);
52775296
}
52785297
return value;
5279-
};
5298+
}
5299+
// Plain object
5300+
for(constkinvalue){
5301+
if(k===__PROTO__){
5302+
delete(value: any)[k];
5303+
} else {
5304+
constwalked=reviveModel(response,(value: any)[k],value,k);
5305+
if(walked!==undefined){
5306+
(value: any)[k]=walked;
5307+
} else {
5308+
delete(value: any)[k];
5309+
}
5310+
}
5311+
}
5312+
returnvalue;
52805313
}
52815314

52825315
exportfunctionclose(weakResponse: WeakResponse): void{

‎packages/react-noop-renderer/src/ReactNoopFlightClient.js‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} =
4343
requireModule(idx: string){
4444
returnreadModule(idx);
4545
},
46-
parseModel(response: Response,json){
47-
returnJSON.parse(json,response._fromJSON);
48-
},
4946
bindToConsole(methodName,args,badgeName){
5047
returnFunction.prototype.bind.apply(
5148
// eslint-disable-next-line react-internal/no-production-logging

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit f247eba

Browse files
[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary Follow-up to vercel/next.js#89823 with the actual changes to React. Replaces the `JSON.parse` reviver callback in `initializeModelChunk` with a two-step approach: plain `JSON.parse()` followed by a recursive `reviveModel()` post-process (same as in Flight Reply Server). This yields a **~75% speedup** in RSC chunk deserialization. | Payload | Original (ms) | Walk (ms) | Speedup | |---------|---------------|-----------|---------| | Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** | | Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** | | Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** | | XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** | | Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** | ## Problem `createFromJSONCallback` returns a reviver function passed as the second argument to `JSON.parse()`. This reviver is called for **every key-value pair** in the parsed JSON. While the logic inside the reviver is lightweight, the dominant cost is the **C++ → JavaScript boundary crossing** — V8's `JSON.parse` is implemented in C++, and calling back into JavaScript for every node incurs significant overhead. Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x slower** than bare `JSON.parse` without a reviver: ``` 108 KB payload: Bare JSON.parse: 0.60 ms Trivial reviver: 2.95 ms (+391%) ``` ## Change Replace the reviver with a two-step process: 1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no callbacks 2. `reviveModel` — recursively walk the resulting object in pure JavaScript to apply RSC transformations The `reviveModel` function includes additional optimizations over the original reviver: - **Short-circuits plain strings**: only calls `parseModelString` when the string starts with `$`, skipping the vast majority of strings (class names, text content, etc.) - **Stays entirely in JavaScript** — no C++ boundary crossings during the walk ## Results You can find the related applications in the [Next.js PR ](vercel/next.js#89823 I've been testing this on Next.js applications. ### Table as Server Component with 1000 items Before: ``` "min": 13.782875000000786, "max": 22.23400000000038, "avg": 17.116868530000083, "p50": 17.10766700000022, "p75": 18.50787499999933, "p95": 20.426249999998618, "p99": 21.814125000000786 ``` After: ``` "min": 10.963916999999128, "max": 18.096083000000363, "avg": 13.543286884999988, "p50": 13.58350000000064, "p75": 14.871791999999914, "p95": 16.08429099999921, "p99": 17.591458000000785 ``` ### Table as Client Component with 1000 items Before: ``` "min": 3.888875000000553, "max": 9.044959000000745, "avg": 4.651271475000067, "p50": 4.555749999999534, "p75": 4.966624999999112, "p95": 5.47754200000054, "p99": 6.109499999998661 ```` After: ``` "min": 3.5986250000005384, "max": 5.374291000000085, "avg": 4.142990245000046, "p50": 4.10570799999914, "p75": 4.392041999999492, "p95": 4.740084000000934, "p99": 5.1652500000000146 ``` ### Nested Suspense Before: ``` Requests: 200 Min: 73ms Max: 106ms Avg: 78ms P50: 77ms P75: 80ms P95: 85ms P99: 94ms ``` After: ``` Requests: 200 Min: 56ms Max: 67ms Avg: 59ms P50: 58ms P75: 60ms P95: 65ms P99: 66ms ``` ### Even more nested Suspense (double-level Suspense) Before: ``` Requests: 200 Min: 159ms Max: 208ms Avg: 169ms P50: 167ms P75: 173ms P95: 183ms P99: 188ms ``` After: ``` Requests: 200 Min: 125ms Max: 170ms Avg: 134ms P50: 132ms P75: 138ms P95: 148ms P99: 160ms ``` ## How did you test this change? Ran it across many Next.js benchmark applications. The entire Next.js test suite passes with this change. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 3a2bee2 commit f247eba

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,6 @@ type Response = {
355355
_encodeFormAction: void|EncodeFormActionCallback,
356356
_nonce: ?string,
357357
_chunks: Map<number, SomeChunk<any>>,
358-
_fromJSON: (key: string,value: JSONValue)=>any,
359358
_stringDecoder: StringDecoder,
360359
_closed: boolean,
361360
_closedReason: mixed,
@@ -2302,6 +2301,11 @@ function defineLazyGetter<T>(
23022301
// TODO: We should ideally throw here to indicate a difference.
23032302
returnOMITTED_PROP_ERROR;
23042303
},
2304+
// no-op: the walk function may try to reassign this property after
2305+
// parseModelString returns. With the JSON.parse reviver, the engine's
2306+
// internal CreateDataProperty silently failed. We use a no-op setter
2307+
// to match that behavior in strict mode.
2308+
set: function(){},
23052309
enumerable: true,
23062310
configurable: false,
23072311
});
@@ -2622,6 +2626,11 @@ function parseModelString(
26222626
// TODO: We should ideally throw here to indicate a difference.
26232627
returnOMITTED_PROP_ERROR;
26242628
},
2629+
// no-op: the walk function may try to reassign this property
2630+
// after parseModelString returns. With the JSON.parse reviver,
2631+
// the engine's internal CreateDataProperty silently failed.
2632+
// We use a no-op setter to match that behavior in strict mode.
2633+
set: function(){},
26252634
enumerable: true,
26262635
configurable: false,
26272636
});
@@ -2699,7 +2708,6 @@ function ResponseInstance(
26992708
this._nonce=nonce;
27002709
this._chunks=chunks;
27012710
this._stringDecoder=createStringDecoder();
2702-
this._fromJSON=(null: any);
27032711
this._closed=false;
27042712
this._closedReason=null;
27052713
this._allowPartialStream=allowPartialStream;
@@ -2783,9 +2791,6 @@ function ResponseInstance(
27832791
markAllTracksInOrder();
27842792
}
27852793
}
2786-
2787-
// Don't inline this call because it causes closure to outline the call above.
2788-
this._fromJSON=createFromJSONCallback(this);
27892794
}
27902795

27912796
exportfunctioncreateResponse(
@@ -5259,24 +5264,52 @@ export function processStringChunk(
52595264
}
52605265

52615266
functionparseModel<T>(response: Response, json: UninitializedModel): T {
5262-
returnJSON.parse(json,response._fromJSON);
5267+
constrawModel=JSON.parse(json);
5268+
// Pass a wrapper object as parentObject to match the original JSON.parse
5269+
// reviver behavior, where the root value's reviver receives {"": rootValue}
5270+
// as `this`. This ensures parentObject is never null when accessed downstream.
5271+
returnreviveModel(response,rawModel,{'': rawModel},'');
52635272
}
52645273

5265-
function createFromJSONCallback(response: Response) {
5266-
// $FlowFixMe[missing-this-annot]
5267-
returnfunction(key: string,value: JSONValue){
5268-
if(key===__PROTO__){
5269-
returnundefined;
5274+
function reviveModel(
5275+
response: Response,
5276+
value: JSONValue,
5277+
parentObject: Object,
5278+
key: string,
5279+
): any {
5280+
if(typeofvalue==='string'){
5281+
if(value[0]==='$'){
5282+
returnparseModelString(response,parentObject,key,value);
52705283
}
5271-
if(typeofvalue==='string'){
5272-
// We can't use .bind here because we need the "this" value.
5273-
returnparseModelString(response,this,key,value);
5284+
return value;
5285+
}
5286+
if(typeofvalue!== 'object' ||value===null){
5287+
returnvalue;
5288+
}
5289+
if (isArray(value)) {
5290+
for(leti=0;i<value.length;i++){
5291+
(value: any)[i]=reviveModel(response,value[i],value,''+i);
52745292
}
5275-
if(typeofvalue==='object'&&value!==null){
5293+
if (value[0] === REACT_ELEMENT_TYPE) {
5294+
// React element tuple
52765295
returnparseModelTuple(response,value);
52775296
}
52785297
return value;
5279-
};
5298+
}
5299+
// Plain object
5300+
for(constkinvalue){
5301+
if(k===__PROTO__){
5302+
delete(value: any)[k];
5303+
} else {
5304+
constwalked=reviveModel(response,(value: any)[k],value,k);
5305+
if(walked!==undefined){
5306+
(value: any)[k]=walked;
5307+
} else {
5308+
delete(value: any)[k];
5309+
}
5310+
}
5311+
}
5312+
returnvalue;
52805313
}
52815314

52825315
exportfunctionclose(weakResponse: WeakResponse): void{

‎packages/react-noop-renderer/src/ReactNoopFlightClient.js‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} =
4343
requireModule(idx: string){
4444
returnreadModule(idx);
4545
},
46-
parseModel(response: Response,json){
47-
returnJSON.parse(json,response._fromJSON);
48-
},
4946
bindToConsole(methodName,args,badgeName){
5047
returnFunction.prototype.bind.apply(
5148
// eslint-disable-next-line react-internal/no-production-logging

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit f247eba

Browse files
[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary Follow-up to vercel/next.js#89823 with the actual changes to React. Replaces the `JSON.parse` reviver callback in `initializeModelChunk` with a two-step approach: plain `JSON.parse()` followed by a recursive `reviveModel()` post-process (same as in Flight Reply Server). This yields a **~75% speedup** in RSC chunk deserialization. | Payload | Original (ms) | Walk (ms) | Speedup | |---------|---------------|-----------|---------| | Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** | | Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** | | Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** | | XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** | | Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** | ## Problem `createFromJSONCallback` returns a reviver function passed as the second argument to `JSON.parse()`. This reviver is called for **every key-value pair** in the parsed JSON. While the logic inside the reviver is lightweight, the dominant cost is the **C++ → JavaScript boundary crossing** — V8's `JSON.parse` is implemented in C++, and calling back into JavaScript for every node incurs significant overhead. Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x slower** than bare `JSON.parse` without a reviver: ``` 108 KB payload: Bare JSON.parse: 0.60 ms Trivial reviver: 2.95 ms (+391%) ``` ## Change Replace the reviver with a two-step process: 1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no callbacks 2. `reviveModel` — recursively walk the resulting object in pure JavaScript to apply RSC transformations The `reviveModel` function includes additional optimizations over the original reviver: - **Short-circuits plain strings**: only calls `parseModelString` when the string starts with `$`, skipping the vast majority of strings (class names, text content, etc.) - **Stays entirely in JavaScript** — no C++ boundary crossings during the walk ## Results You can find the related applications in the [Next.js PR ](vercel/next.js#89823 I've been testing this on Next.js applications. ### Table as Server Component with 1000 items Before: ``` "min": 13.782875000000786, "max": 22.23400000000038, "avg": 17.116868530000083, "p50": 17.10766700000022, "p75": 18.50787499999933, "p95": 20.426249999998618, "p99": 21.814125000000786 ``` After: ``` "min": 10.963916999999128, "max": 18.096083000000363, "avg": 13.543286884999988, "p50": 13.58350000000064, "p75": 14.871791999999914, "p95": 16.08429099999921, "p99": 17.591458000000785 ``` ### Table as Client Component with 1000 items Before: ``` "min": 3.888875000000553, "max": 9.044959000000745, "avg": 4.651271475000067, "p50": 4.555749999999534, "p75": 4.966624999999112, "p95": 5.47754200000054, "p99": 6.109499999998661 ```` After: ``` "min": 3.5986250000005384, "max": 5.374291000000085, "avg": 4.142990245000046, "p50": 4.10570799999914, "p75": 4.392041999999492, "p95": 4.740084000000934, "p99": 5.1652500000000146 ``` ### Nested Suspense Before: ``` Requests: 200 Min: 73ms Max: 106ms Avg: 78ms P50: 77ms P75: 80ms P95: 85ms P99: 94ms ``` After: ``` Requests: 200 Min: 56ms Max: 67ms Avg: 59ms P50: 58ms P75: 60ms P95: 65ms P99: 66ms ``` ### Even more nested Suspense (double-level Suspense) Before: ``` Requests: 200 Min: 159ms Max: 208ms Avg: 169ms P50: 167ms P75: 173ms P95: 183ms P99: 188ms ``` After: ``` Requests: 200 Min: 125ms Max: 170ms Avg: 134ms P50: 132ms P75: 138ms P95: 148ms P99: 160ms ``` ## How did you test this change? Ran it across many Next.js benchmark applications. The entire Next.js test suite passes with this change. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 3a2bee2 commit f247eba

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,6 @@ type Response = {
355355
_encodeFormAction: void|EncodeFormActionCallback,
356356
_nonce: ?string,
357357
_chunks: Map<number, SomeChunk<any>>,
358-
_fromJSON: (key: string,value: JSONValue)=>any,
359358
_stringDecoder: StringDecoder,
360359
_closed: boolean,
361360
_closedReason: mixed,
@@ -2302,6 +2301,11 @@ function defineLazyGetter<T>(
23022301
// TODO: We should ideally throw here to indicate a difference.
23032302
returnOMITTED_PROP_ERROR;
23042303
},
2304+
// no-op: the walk function may try to reassign this property after
2305+
// parseModelString returns. With the JSON.parse reviver, the engine's
2306+
// internal CreateDataProperty silently failed. We use a no-op setter
2307+
// to match that behavior in strict mode.
2308+
set: function(){},
23052309
enumerable: true,
23062310
configurable: false,
23072311
});
@@ -2622,6 +2626,11 @@ function parseModelString(
26222626
// TODO: We should ideally throw here to indicate a difference.
26232627
returnOMITTED_PROP_ERROR;
26242628
},
2629+
// no-op: the walk function may try to reassign this property
2630+
// after parseModelString returns. With the JSON.parse reviver,
2631+
// the engine's internal CreateDataProperty silently failed.
2632+
// We use a no-op setter to match that behavior in strict mode.
2633+
set: function(){},
26252634
enumerable: true,
26262635
configurable: false,
26272636
});
@@ -2699,7 +2708,6 @@ function ResponseInstance(
26992708
this._nonce=nonce;
27002709
this._chunks=chunks;
27012710
this._stringDecoder=createStringDecoder();
2702-
this._fromJSON=(null: any);
27032711
this._closed=false;
27042712
this._closedReason=null;
27052713
this._allowPartialStream=allowPartialStream;
@@ -2783,9 +2791,6 @@ function ResponseInstance(
27832791
markAllTracksInOrder();
27842792
}
27852793
}
2786-
2787-
// Don't inline this call because it causes closure to outline the call above.
2788-
this._fromJSON=createFromJSONCallback(this);
27892794
}
27902795

27912796
exportfunctioncreateResponse(
@@ -5259,24 +5264,52 @@ export function processStringChunk(
52595264
}
52605265

52615266
functionparseModel<T>(response: Response, json: UninitializedModel): T {
5262-
returnJSON.parse(json,response._fromJSON);
5267+
constrawModel=JSON.parse(json);
5268+
// Pass a wrapper object as parentObject to match the original JSON.parse
5269+
// reviver behavior, where the root value's reviver receives {"": rootValue}
5270+
// as `this`. This ensures parentObject is never null when accessed downstream.
5271+
returnreviveModel(response,rawModel,{'': rawModel},'');
52635272
}
52645273

5265-
function createFromJSONCallback(response: Response) {
5266-
// $FlowFixMe[missing-this-annot]
5267-
returnfunction(key: string,value: JSONValue){
5268-
if(key===__PROTO__){
5269-
returnundefined;
5274+
function reviveModel(
5275+
response: Response,
5276+
value: JSONValue,
5277+
parentObject: Object,
5278+
key: string,
5279+
): any {
5280+
if(typeofvalue==='string'){
5281+
if(value[0]==='$'){
5282+
returnparseModelString(response,parentObject,key,value);
52705283
}
5271-
if(typeofvalue==='string'){
5272-
// We can't use .bind here because we need the "this" value.
5273-
returnparseModelString(response,this,key,value);
5284+
return value;
5285+
}
5286+
if(typeofvalue!== 'object' ||value===null){
5287+
returnvalue;
5288+
}
5289+
if (isArray(value)) {
5290+
for(leti=0;i<value.length;i++){
5291+
(value: any)[i]=reviveModel(response,value[i],value,''+i);
52745292
}
5275-
if(typeofvalue==='object'&&value!==null){
5293+
if (value[0] === REACT_ELEMENT_TYPE) {
5294+
// React element tuple
52765295
returnparseModelTuple(response,value);
52775296
}
52785297
return value;
5279-
};
5298+
}
5299+
// Plain object
5300+
for(constkinvalue){
5301+
if(k===__PROTO__){
5302+
delete(value: any)[k];
5303+
} else {
5304+
constwalked=reviveModel(response,(value: any)[k],value,k);
5305+
if(walked!==undefined){
5306+
(value: any)[k]=walked;
5307+
} else {
5308+
delete(value: any)[k];
5309+
}
5310+
}
5311+
}
5312+
returnvalue;
52805313
}
52815314

52825315
exportfunctionclose(weakResponse: WeakResponse): void{

‎packages/react-noop-renderer/src/ReactNoopFlightClient.js‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} =
4343
requireModule(idx: string){
4444
returnreadModule(idx);
4545
},
46-
parseModel(response: Response,json){
47-
returnJSON.parse(json,response._fromJSON);
48-
},
4946
bindToConsole(methodName,args,badgeName){
5047
returnFunction.prototype.bind.apply(
5148
// eslint-disable-next-line react-internal/no-production-logging

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit f247eba

Browse files
[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary Follow-up to vercel/next.js#89823 with the actual changes to React. Replaces the `JSON.parse` reviver callback in `initializeModelChunk` with a two-step approach: plain `JSON.parse()` followed by a recursive `reviveModel()` post-process (same as in Flight Reply Server). This yields a **~75% speedup** in RSC chunk deserialization. | Payload | Original (ms) | Walk (ms) | Speedup | |---------|---------------|-----------|---------| | Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** | | Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** | | Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** | | XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** | | Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** | ## Problem `createFromJSONCallback` returns a reviver function passed as the second argument to `JSON.parse()`. This reviver is called for **every key-value pair** in the parsed JSON. While the logic inside the reviver is lightweight, the dominant cost is the **C++ → JavaScript boundary crossing** — V8's `JSON.parse` is implemented in C++, and calling back into JavaScript for every node incurs significant overhead. Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x slower** than bare `JSON.parse` without a reviver: ``` 108 KB payload: Bare JSON.parse: 0.60 ms Trivial reviver: 2.95 ms (+391%) ``` ## Change Replace the reviver with a two-step process: 1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no callbacks 2. `reviveModel` — recursively walk the resulting object in pure JavaScript to apply RSC transformations The `reviveModel` function includes additional optimizations over the original reviver: - **Short-circuits plain strings**: only calls `parseModelString` when the string starts with `$`, skipping the vast majority of strings (class names, text content, etc.) - **Stays entirely in JavaScript** — no C++ boundary crossings during the walk ## Results You can find the related applications in the [Next.js PR ](vercel/next.js#89823 I've been testing this on Next.js applications. ### Table as Server Component with 1000 items Before: ``` "min": 13.782875000000786, "max": 22.23400000000038, "avg": 17.116868530000083, "p50": 17.10766700000022, "p75": 18.50787499999933, "p95": 20.426249999998618, "p99": 21.814125000000786 ``` After: ``` "min": 10.963916999999128, "max": 18.096083000000363, "avg": 13.543286884999988, "p50": 13.58350000000064, "p75": 14.871791999999914, "p95": 16.08429099999921, "p99": 17.591458000000785 ``` ### Table as Client Component with 1000 items Before: ``` "min": 3.888875000000553, "max": 9.044959000000745, "avg": 4.651271475000067, "p50": 4.555749999999534, "p75": 4.966624999999112, "p95": 5.47754200000054, "p99": 6.109499999998661 ```` After: ``` "min": 3.5986250000005384, "max": 5.374291000000085, "avg": 4.142990245000046, "p50": 4.10570799999914, "p75": 4.392041999999492, "p95": 4.740084000000934, "p99": 5.1652500000000146 ``` ### Nested Suspense Before: ``` Requests: 200 Min: 73ms Max: 106ms Avg: 78ms P50: 77ms P75: 80ms P95: 85ms P99: 94ms ``` After: ``` Requests: 200 Min: 56ms Max: 67ms Avg: 59ms P50: 58ms P75: 60ms P95: 65ms P99: 66ms ``` ### Even more nested Suspense (double-level Suspense) Before: ``` Requests: 200 Min: 159ms Max: 208ms Avg: 169ms P50: 167ms P75: 173ms P95: 183ms P99: 188ms ``` After: ``` Requests: 200 Min: 125ms Max: 170ms Avg: 134ms P50: 132ms P75: 138ms P95: 148ms P99: 160ms ``` ## How did you test this change? Ran it across many Next.js benchmark applications. The entire Next.js test suite passes with this change. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 3a2bee2 commit f247eba

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,6 @@ type Response = {
355355
_encodeFormAction: void|EncodeFormActionCallback,
356356
_nonce: ?string,
357357
_chunks: Map<number, SomeChunk<any>>,
358-
_fromJSON: (key: string,value: JSONValue)=>any,
359358
_stringDecoder: StringDecoder,
360359
_closed: boolean,
361360
_closedReason: mixed,
@@ -2302,6 +2301,11 @@ function defineLazyGetter<T>(
23022301
// TODO: We should ideally throw here to indicate a difference.
23032302
returnOMITTED_PROP_ERROR;
23042303
},
2304+
// no-op: the walk function may try to reassign this property after
2305+
// parseModelString returns. With the JSON.parse reviver, the engine's
2306+
// internal CreateDataProperty silently failed. We use a no-op setter
2307+
// to match that behavior in strict mode.
2308+
set: function(){},
23052309
enumerable: true,
23062310
configurable: false,
23072311
});
@@ -2622,6 +2626,11 @@ function parseModelString(
26222626
// TODO: We should ideally throw here to indicate a difference.
26232627
returnOMITTED_PROP_ERROR;
26242628
},
2629+
// no-op: the walk function may try to reassign this property
2630+
// after parseModelString returns. With the JSON.parse reviver,
2631+
// the engine's internal CreateDataProperty silently failed.
2632+
// We use a no-op setter to match that behavior in strict mode.
2633+
set: function(){},
26252634
enumerable: true,
26262635
configurable: false,
26272636
});
@@ -2699,7 +2708,6 @@ function ResponseInstance(
26992708
this._nonce=nonce;
27002709
this._chunks=chunks;
27012710
this._stringDecoder=createStringDecoder();
2702-
this._fromJSON=(null: any);
27032711
this._closed=false;
27042712
this._closedReason=null;
27052713
this._allowPartialStream=allowPartialStream;
@@ -2783,9 +2791,6 @@ function ResponseInstance(
27832791
markAllTracksInOrder();
27842792
}
27852793
}
2786-
2787-
// Don't inline this call because it causes closure to outline the call above.
2788-
this._fromJSON=createFromJSONCallback(this);
27892794
}
27902795

27912796
exportfunctioncreateResponse(
@@ -5259,24 +5264,52 @@ export function processStringChunk(
52595264
}
52605265

52615266
functionparseModel<T>(response: Response, json: UninitializedModel): T {
5262-
returnJSON.parse(json,response._fromJSON);
5267+
constrawModel=JSON.parse(json);
5268+
// Pass a wrapper object as parentObject to match the original JSON.parse
5269+
// reviver behavior, where the root value's reviver receives {"": rootValue}
5270+
// as `this`. This ensures parentObject is never null when accessed downstream.
5271+
returnreviveModel(response,rawModel,{'': rawModel},'');
52635272
}
52645273

5265-
function createFromJSONCallback(response: Response) {
5266-
// $FlowFixMe[missing-this-annot]
5267-
returnfunction(key: string,value: JSONValue){
5268-
if(key===__PROTO__){
5269-
returnundefined;
5274+
function reviveModel(
5275+
response: Response,
5276+
value: JSONValue,
5277+
parentObject: Object,
5278+
key: string,
5279+
): any {
5280+
if(typeofvalue==='string'){
5281+
if(value[0]==='$'){
5282+
returnparseModelString(response,parentObject,key,value);
52705283
}
5271-
if(typeofvalue==='string'){
5272-
// We can't use .bind here because we need the "this" value.
5273-
returnparseModelString(response,this,key,value);
5284+
return value;
5285+
}
5286+
if(typeofvalue!== 'object' ||value===null){
5287+
returnvalue;
5288+
}
5289+
if (isArray(value)) {
5290+
for(leti=0;i<value.length;i++){
5291+
(value: any)[i]=reviveModel(response,value[i],value,''+i);
52745292
}
5275-
if(typeofvalue==='object'&&value!==null){
5293+
if (value[0] === REACT_ELEMENT_TYPE) {
5294+
// React element tuple
52765295
returnparseModelTuple(response,value);
52775296
}
52785297
return value;
5279-
};
5298+
}
5299+
// Plain object
5300+
for(constkinvalue){
5301+
if(k===__PROTO__){
5302+
delete(value: any)[k];
5303+
} else {
5304+
constwalked=reviveModel(response,(value: any)[k],value,k);
5305+
if(walked!==undefined){
5306+
(value: any)[k]=walked;
5307+
} else {
5308+
delete(value: any)[k];
5309+
}
5310+
}
5311+
}
5312+
returnvalue;
52805313
}
52815314

52825315
exportfunctionclose(weakResponse: WeakResponse): void{

‎packages/react-noop-renderer/src/ReactNoopFlightClient.js‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} =
4343
requireModule(idx: string){
4444
returnreadModule(idx);
4545
},
46-
parseModel(response: Response,json){
47-
returnJSON.parse(json,response._fromJSON);
48-
},
4946
bindToConsole(methodName,args,badgeName){
5047
returnFunction.prototype.bind.apply(
5148
// eslint-disable-next-line react-internal/no-production-logging

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit f247eba

Browse files
[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary Follow-up to vercel/next.js#89823 with the actual changes to React. Replaces the `JSON.parse` reviver callback in `initializeModelChunk` with a two-step approach: plain `JSON.parse()` followed by a recursive `reviveModel()` post-process (same as in Flight Reply Server). This yields a **~75% speedup** in RSC chunk deserialization. | Payload | Original (ms) | Walk (ms) | Speedup | |---------|---------------|-----------|---------| | Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** | | Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** | | Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** | | XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** | | Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** | ## Problem `createFromJSONCallback` returns a reviver function passed as the second argument to `JSON.parse()`. This reviver is called for **every key-value pair** in the parsed JSON. While the logic inside the reviver is lightweight, the dominant cost is the **C++ → JavaScript boundary crossing** — V8's `JSON.parse` is implemented in C++, and calling back into JavaScript for every node incurs significant overhead. Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x slower** than bare `JSON.parse` without a reviver: ``` 108 KB payload: Bare JSON.parse: 0.60 ms Trivial reviver: 2.95 ms (+391%) ``` ## Change Replace the reviver with a two-step process: 1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no callbacks 2. `reviveModel` — recursively walk the resulting object in pure JavaScript to apply RSC transformations The `reviveModel` function includes additional optimizations over the original reviver: - **Short-circuits plain strings**: only calls `parseModelString` when the string starts with `$`, skipping the vast majority of strings (class names, text content, etc.) - **Stays entirely in JavaScript** — no C++ boundary crossings during the walk ## Results You can find the related applications in the [Next.js PR ](vercel/next.js#89823 I've been testing this on Next.js applications. ### Table as Server Component with 1000 items Before: ``` "min": 13.782875000000786, "max": 22.23400000000038, "avg": 17.116868530000083, "p50": 17.10766700000022, "p75": 18.50787499999933, "p95": 20.426249999998618, "p99": 21.814125000000786 ``` After: ``` "min": 10.963916999999128, "max": 18.096083000000363, "avg": 13.543286884999988, "p50": 13.58350000000064, "p75": 14.871791999999914, "p95": 16.08429099999921, "p99": 17.591458000000785 ``` ### Table as Client Component with 1000 items Before: ``` "min": 3.888875000000553, "max": 9.044959000000745, "avg": 4.651271475000067, "p50": 4.555749999999534, "p75": 4.966624999999112, "p95": 5.47754200000054, "p99": 6.109499999998661 ```` After: ``` "min": 3.5986250000005384, "max": 5.374291000000085, "avg": 4.142990245000046, "p50": 4.10570799999914, "p75": 4.392041999999492, "p95": 4.740084000000934, "p99": 5.1652500000000146 ``` ### Nested Suspense Before: ``` Requests: 200 Min: 73ms Max: 106ms Avg: 78ms P50: 77ms P75: 80ms P95: 85ms P99: 94ms ``` After: ``` Requests: 200 Min: 56ms Max: 67ms Avg: 59ms P50: 58ms P75: 60ms P95: 65ms P99: 66ms ``` ### Even more nested Suspense (double-level Suspense) Before: ``` Requests: 200 Min: 159ms Max: 208ms Avg: 169ms P50: 167ms P75: 173ms P95: 183ms P99: 188ms ``` After: ``` Requests: 200 Min: 125ms Max: 170ms Avg: 134ms P50: 132ms P75: 138ms P95: 148ms P99: 160ms ``` ## How did you test this change? Ran it across many Next.js benchmark applications. The entire Next.js test suite passes with this change. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 3a2bee2 commit f247eba

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,6 @@ type Response = {
355355
_encodeFormAction: void|EncodeFormActionCallback,
356356
_nonce: ?string,
357357
_chunks: Map<number, SomeChunk<any>>,
358-
_fromJSON: (key: string,value: JSONValue)=>any,
359358
_stringDecoder: StringDecoder,
360359
_closed: boolean,
361360
_closedReason: mixed,
@@ -2302,6 +2301,11 @@ function defineLazyGetter<T>(
23022301
// TODO: We should ideally throw here to indicate a difference.
23032302
returnOMITTED_PROP_ERROR;
23042303
},
2304+
// no-op: the walk function may try to reassign this property after
2305+
// parseModelString returns. With the JSON.parse reviver, the engine's
2306+
// internal CreateDataProperty silently failed. We use a no-op setter
2307+
// to match that behavior in strict mode.
2308+
set: function(){},
23052309
enumerable: true,
23062310
configurable: false,
23072311
});
@@ -2622,6 +2626,11 @@ function parseModelString(
26222626
// TODO: We should ideally throw here to indicate a difference.
26232627
returnOMITTED_PROP_ERROR;
26242628
},
2629+
// no-op: the walk function may try to reassign this property
2630+
// after parseModelString returns. With the JSON.parse reviver,
2631+
// the engine's internal CreateDataProperty silently failed.
2632+
// We use a no-op setter to match that behavior in strict mode.
2633+
set: function(){},
26252634
enumerable: true,
26262635
configurable: false,
26272636
});
@@ -2699,7 +2708,6 @@ function ResponseInstance(
26992708
this._nonce=nonce;
27002709
this._chunks=chunks;
27012710
this._stringDecoder=createStringDecoder();
2702-
this._fromJSON=(null: any);
27032711
this._closed=false;
27042712
this._closedReason=null;
27052713
this._allowPartialStream=allowPartialStream;
@@ -2783,9 +2791,6 @@ function ResponseInstance(
27832791
markAllTracksInOrder();
27842792
}
27852793
}
2786-
2787-
// Don't inline this call because it causes closure to outline the call above.
2788-
this._fromJSON=createFromJSONCallback(this);
27892794
}
27902795

27912796
exportfunctioncreateResponse(
@@ -5259,24 +5264,52 @@ export function processStringChunk(
52595264
}
52605265

52615266
functionparseModel<T>(response: Response, json: UninitializedModel): T {
5262-
returnJSON.parse(json,response._fromJSON);
5267+
constrawModel=JSON.parse(json);
5268+
// Pass a wrapper object as parentObject to match the original JSON.parse
5269+
// reviver behavior, where the root value's reviver receives {"": rootValue}
5270+
// as `this`. This ensures parentObject is never null when accessed downstream.
5271+
returnreviveModel(response,rawModel,{'': rawModel},'');
52635272
}
52645273

5265-
function createFromJSONCallback(response: Response) {
5266-
// $FlowFixMe[missing-this-annot]
5267-
returnfunction(key: string,value: JSONValue){
5268-
if(key===__PROTO__){
5269-
returnundefined;
5274+
function reviveModel(
5275+
response: Response,
5276+
value: JSONValue,
5277+
parentObject: Object,
5278+
key: string,
5279+
): any {
5280+
if(typeofvalue==='string'){
5281+
if(value[0]==='$'){
5282+
returnparseModelString(response,parentObject,key,value);
52705283
}
5271-
if(typeofvalue==='string'){
5272-
// We can't use .bind here because we need the "this" value.
5273-
returnparseModelString(response,this,key,value);
5284+
return value;
5285+
}
5286+
if(typeofvalue!== 'object' ||value===null){
5287+
returnvalue;
5288+
}
5289+
if (isArray(value)) {
5290+
for(leti=0;i<value.length;i++){
5291+
(value: any)[i]=reviveModel(response,value[i],value,''+i);
52745292
}
5275-
if(typeofvalue==='object'&&value!==null){
5293+
if (value[0] === REACT_ELEMENT_TYPE) {
5294+
// React element tuple
52765295
returnparseModelTuple(response,value);
52775296
}
52785297
return value;
5279-
};
5298+
}
5299+
// Plain object
5300+
for(constkinvalue){
5301+
if(k===__PROTO__){
5302+
delete(value: any)[k];
5303+
} else {
5304+
constwalked=reviveModel(response,(value: any)[k],value,k);
5305+
if(walked!==undefined){
5306+
(value: any)[k]=walked;
5307+
} else {
5308+
delete(value: any)[k];
5309+
}
5310+
}
5311+
}
5312+
returnvalue;
52805313
}
52815314

52825315
exportfunctionclose(weakResponse: WeakResponse): void{

‎packages/react-noop-renderer/src/ReactNoopFlightClient.js‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} =
4343
requireModule(idx: string){
4444
returnreadModule(idx);
4545
},
46-
parseModel(response: Response,json){
47-
returnJSON.parse(json,response._fromJSON);
48-
},
4946
bindToConsole(methodName,args,badgeName){
5047
returnFunction.prototype.bind.apply(
5148
// eslint-disable-next-line react-internal/no-production-logging

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit f247eba

Browse files
[Flight] Walk parsed JSON instead of using reviver for parsing RSC payload (#35776)
## Summary Follow-up to vercel/next.js#89823 with the actual changes to React. Replaces the `JSON.parse` reviver callback in `initializeModelChunk` with a two-step approach: plain `JSON.parse()` followed by a recursive `reviveModel()` post-process (same as in Flight Reply Server). This yields a **~75% speedup** in RSC chunk deserialization. | Payload | Original (ms) | Walk (ms) | Speedup | |---------|---------------|-----------|---------| | Small (2 elements, 142B) | 0.0024 | 0.0007 | **+72%** | | Medium (~12 elements, 914B) | 0.0116 | 0.0031 | **+73%** | | Large (~90 elements, 16.7KB) | 0.1836 | 0.0451 | **+75%** | | XL (~200 elements, 25.7KB) | 0.3742 | 0.0913 | **+76%** | | Table (1000 rows, 110KB) | 3.0862 | 0.6887 | **+78%** | ## Problem `createFromJSONCallback` returns a reviver function passed as the second argument to `JSON.parse()`. This reviver is called for **every key-value pair** in the parsed JSON. While the logic inside the reviver is lightweight, the dominant cost is the **C++ → JavaScript boundary crossing** — V8's `JSON.parse` is implemented in C++, and calling back into JavaScript for every node incurs significant overhead. Even a trivial no-op reviver `(k, v) => v` makes `JSON.parse` **~4x slower** than bare `JSON.parse` without a reviver: ``` 108 KB payload: Bare JSON.parse: 0.60 ms Trivial reviver: 2.95 ms (+391%) ``` ## Change Replace the reviver with a two-step process: 1. `JSON.parse(resolvedModel)` — parse the entire payload in C++ with no callbacks 2. `reviveModel` — recursively walk the resulting object in pure JavaScript to apply RSC transformations The `reviveModel` function includes additional optimizations over the original reviver: - **Short-circuits plain strings**: only calls `parseModelString` when the string starts with `$`, skipping the vast majority of strings (class names, text content, etc.) - **Stays entirely in JavaScript** — no C++ boundary crossings during the walk ## Results You can find the related applications in the [Next.js PR ](vercel/next.js#89823 I've been testing this on Next.js applications. ### Table as Server Component with 1000 items Before: ``` "min": 13.782875000000786, "max": 22.23400000000038, "avg": 17.116868530000083, "p50": 17.10766700000022, "p75": 18.50787499999933, "p95": 20.426249999998618, "p99": 21.814125000000786 ``` After: ``` "min": 10.963916999999128, "max": 18.096083000000363, "avg": 13.543286884999988, "p50": 13.58350000000064, "p75": 14.871791999999914, "p95": 16.08429099999921, "p99": 17.591458000000785 ``` ### Table as Client Component with 1000 items Before: ``` "min": 3.888875000000553, "max": 9.044959000000745, "avg": 4.651271475000067, "p50": 4.555749999999534, "p75": 4.966624999999112, "p95": 5.47754200000054, "p99": 6.109499999998661 ```` After: ``` "min": 3.5986250000005384, "max": 5.374291000000085, "avg": 4.142990245000046, "p50": 4.10570799999914, "p75": 4.392041999999492, "p95": 4.740084000000934, "p99": 5.1652500000000146 ``` ### Nested Suspense Before: ``` Requests: 200 Min: 73ms Max: 106ms Avg: 78ms P50: 77ms P75: 80ms P95: 85ms P99: 94ms ``` After: ``` Requests: 200 Min: 56ms Max: 67ms Avg: 59ms P50: 58ms P75: 60ms P95: 65ms P99: 66ms ``` ### Even more nested Suspense (double-level Suspense) Before: ``` Requests: 200 Min: 159ms Max: 208ms Avg: 169ms P50: 167ms P75: 173ms P95: 183ms P99: 188ms ``` After: ``` Requests: 200 Min: 125ms Max: 170ms Avg: 134ms P50: 132ms P75: 138ms P95: 148ms P99: 160ms ``` ## How did you test this change? Ran it across many Next.js benchmark applications. The entire Next.js test suite passes with this change. --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 3a2bee2 commit f247eba

2 files changed

Lines changed: 49 additions & 19 deletions

File tree

‎packages/react-client/src/ReactFlightClient.js‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,6 @@ type Response = {
355355
_encodeFormAction: void|EncodeFormActionCallback,
356356
_nonce: ?string,
357357
_chunks: Map<number, SomeChunk<any>>,
358-
_fromJSON: (key: string,value: JSONValue)=>any,
359358
_stringDecoder: StringDecoder,
360359
_closed: boolean,
361360
_closedReason: mixed,
@@ -2302,6 +2301,11 @@ function defineLazyGetter<T>(
23022301
// TODO: We should ideally throw here to indicate a difference.
23032302
returnOMITTED_PROP_ERROR;
23042303
},
2304+
// no-op: the walk function may try to reassign this property after
2305+
// parseModelString returns. With the JSON.parse reviver, the engine's
2306+
// internal CreateDataProperty silently failed. We use a no-op setter
2307+
// to match that behavior in strict mode.
2308+
set: function(){},
23052309
enumerable: true,
23062310
configurable: false,
23072311
});
@@ -2622,6 +2626,11 @@ function parseModelString(
26222626
// TODO: We should ideally throw here to indicate a difference.
26232627
returnOMITTED_PROP_ERROR;
26242628
},
2629+
// no-op: the walk function may try to reassign this property
2630+
// after parseModelString returns. With the JSON.parse reviver,
2631+
// the engine's internal CreateDataProperty silently failed.
2632+
// We use a no-op setter to match that behavior in strict mode.
2633+
set: function(){},
26252634
enumerable: true,
26262635
configurable: false,
26272636
});
@@ -2699,7 +2708,6 @@ function ResponseInstance(
26992708
this._nonce=nonce;
27002709
this._chunks=chunks;
27012710
this._stringDecoder=createStringDecoder();
2702-
this._fromJSON=(null: any);
27032711
this._closed=false;
27042712
this._closedReason=null;
27052713
this._allowPartialStream=allowPartialStream;
@@ -2783,9 +2791,6 @@ function ResponseInstance(
27832791
markAllTracksInOrder();
27842792
}
27852793
}
2786-
2787-
// Don't inline this call because it causes closure to outline the call above.
2788-
this._fromJSON=createFromJSONCallback(this);
27892794
}
27902795

27912796
exportfunctioncreateResponse(
@@ -5259,24 +5264,52 @@ export function processStringChunk(
52595264
}
52605265

52615266
functionparseModel<T>(response: Response, json: UninitializedModel): T {
5262-
returnJSON.parse(json,response._fromJSON);
5267+
constrawModel=JSON.parse(json);
5268+
// Pass a wrapper object as parentObject to match the original JSON.parse
5269+
// reviver behavior, where the root value's reviver receives {"": rootValue}
5270+
// as `this`. This ensures parentObject is never null when accessed downstream.
5271+
returnreviveModel(response,rawModel,{'': rawModel},'');
52635272
}
52645273

5265-
function createFromJSONCallback(response: Response) {
5266-
// $FlowFixMe[missing-this-annot]
5267-
returnfunction(key: string,value: JSONValue){
5268-
if(key===__PROTO__){
5269-
returnundefined;
5274+
function reviveModel(
5275+
response: Response,
5276+
value: JSONValue,
5277+
parentObject: Object,
5278+
key: string,
5279+
): any {
5280+
if(typeofvalue==='string'){
5281+
if(value[0]==='$'){
5282+
returnparseModelString(response,parentObject,key,value);
52705283
}
5271-
if(typeofvalue==='string'){
5272-
// We can't use .bind here because we need the "this" value.
5273-
returnparseModelString(response,this,key,value);
5284+
return value;
5285+
}
5286+
if(typeofvalue!== 'object' ||value===null){
5287+
returnvalue;
5288+
}
5289+
if (isArray(value)) {
5290+
for(leti=0;i<value.length;i++){
5291+
(value: any)[i]=reviveModel(response,value[i],value,''+i);
52745292
}
5275-
if(typeofvalue==='object'&&value!==null){
5293+
if (value[0] === REACT_ELEMENT_TYPE) {
5294+
// React element tuple
52765295
returnparseModelTuple(response,value);
52775296
}
52785297
return value;
5279-
};
5298+
}
5299+
// Plain object
5300+
for(constkinvalue){
5301+
if(k===__PROTO__){
5302+
delete(value: any)[k];
5303+
} else {
5304+
constwalked=reviveModel(response,(value: any)[k],value,k);
5305+
if(walked!==undefined){
5306+
(value: any)[k]=walked;
5307+
} else {
5308+
delete(value: any)[k];
5309+
}
5310+
}
5311+
}
5312+
returnvalue;
52805313
}
52815314

52825315
exportfunctionclose(weakResponse: WeakResponse): void{

‎packages/react-noop-renderer/src/ReactNoopFlightClient.js‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ const {createResponse, createStreamState, processBinaryChunk, getRoot, close} =
4343
requireModule(idx: string){
4444
returnreadModule(idx);
4545
},
46-
parseModel(response: Response,json){
47-
returnJSON.parse(json,response._fromJSON);
48-
},
4946
bindToConsole(methodName,args,badgeName){
5047
returnFunction.prototype.bind.apply(
5148
// eslint-disable-next-line react-internal/no-production-logging

0 commit comments

Comments
 (0)