propsを1つ増やしただけなのにレスポンスサイズが5倍に増えてしまった
ある日、Vercelのダッシュボードを見ると以前はだいたい100KB前後だったページのレスポンスサイズが500KB前後に増えてしまっていました。
原因はサーバコンポーネントからクライアントコンポーネントに渡すpropsが巨大だったことでした。
SCからCCに渡したpropsはRSCペイロードという形で初回レスポンスのHTMLに含まれるためです。
分かってしまえば単純なことなのですが、当時Next.jsのバージョンアップなどの改修も同時に行なっていたために原因調査に手こずりました。
そんなわけで、レスポンスサイズが5倍になったときの調査に役に立つかもしれない(?)RSCペイロードの覗き方を簡潔にお伝えします。
RSCペイロードを覗く
Next.jsでRSCペイロードがどう扱われているのか、実際に覗いてみます。
以下の3つのファイルを用意して確かめます。
import ClientComponent from "./client-component";
import ServerComponent from "./server-component";
export default function Page() {
return (
<main>
<ServerComponent />
<ClientComponent />
</main>
);
}
export default function ServerComponent() {
return <div>Hi there! I'm a Server Component!</div>;
}
"use client";
export default function ClientComponent() {
return <div>Hi there! I'm a Client Component!</div>;
}
このファイルを置いた状態でNext.jsのサーバーを起動してトップページにアクセスします。
レスポンスのHTMLを眺めるとself.__next_f.push
というスクリプトを含んだscriptタグが含まれているのが分かります。
その中から1つ取り出してみました。
<script>
self.__next_f.push([1, "28:D\"$29\"\n2d:D\"$2e\"\n2d:[\"$\",\"div\",null,{\"children\":\"Hi there! I'm a Server Component!\"},\"$2e\",\"$2f\",1]\n28:[\"$\",\"main\",null,{\"children\":[\"$2d\",[\"$\",\"$L31\",null,{},\"$29\",\"$30\",1]]},\"$29\",\"$2c\",1]\n36:D\"$37\"\n39:D\"$3a\"\n39:[\"$\",\"$L3c\",null,{\"promise\":\"$@3d\"},\"$3a\",\"$3b\",1]\n3f:D\"$40\"\n3f:null\n41:D\"$42\"\n45:D\"$46\"\n41:[[\"$\",\"$L44\",null,{\"children\":\"$L45\"},\"$42\",\"$43\",1],null]\n47:D\"$48\"\n4b:D\"$4c\"\n50:D\"$51\"\n4b:[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$4f\",null,{\"fallback\":null,\"children\":\"$L50\"},\"$4c\",\"$4e\",1]},\"$4c\",\"$4d\",1]\n47:[\"$\",\"$L4a\",null,{\"children\":\"$4b\"},\"$48\",\"$49\",1]\n52:[]\n"])
</script>
このスクリプトの文字列の部分がペイロードになっています。
このままでは見づらいですね。改行コードが多く含まれてるので改行を反映してフォーマットしてみます。
28:D"$29"
2d:D"$2e"
2d:["$","div",null,{"children":"Hi there! I'm a Server Component!"},"$2e","$2f",1]
28:["$","main",null,{"children":["$2d",["$","$L31",null,{},"$29","$30",1]]},"$29","$2c",1]
36:D"$37"
39:D"$3a"
39:["$","$L3c",null,{"promise":"$@3d"},"$3a","$3b",1]
3f:D"$40"
3f:null
41:D"$42"
45:D"$46"
41:[["$","$L44",null,{"children":"$L45"},"$42","$43",1],null]
47:D"$48"
4b:D"$4c"
50:D"$51"
4b:["$","div",null,{"hidden":true,"children":["$","$4f",null,{"fallback":null,"children":"$L50"},"$4c","$4e",1]},"$4c","$4d",1]
47:["$","$L4a",null,{"children":"$4b"},"$48","$49",1]
52:[]
よく分からないフォーマットですが、注目すべきは3行目と4行目です。
2d:["$","div",null,{"children":"Hi there! I'm a Server Component!"},"$2e","$2f",1]
28:["$","main",null,{"children":["$2d",["$","$L31",null,{},"$29","$30",1]]},"$29","$2c",1]
2dから始まる行はchildren
にHi there! I'm a Server Component!
とあるのでServerComponent
のレンダリング結果のように見えます。
28から始まる行は"main"
とあるのでPage
のレンダリング結果のように見えます。children
の配列の最初の要素が"$2d"
となっていて上記のServerComponent
を参照してそうな雰囲気もあります。
続けてapp/page.tsx
を以下のように変えて再度ペイロードを覗いてみます。
import ClientComponent from "./client-component";
import ServerComponent from "./server-component";
export default function Page() {
return (
<main>
<ServerComponent
key="server"
string="string"
number={0}
boolean={true}
null={null}
array={[1, "string", false]}
object={{ key: "value" }}
/>
<ClientComponent
key="client"
string="string"
number={0}
boolean={true}
null={null}
array={[1, "string", false]}
object={{ key: "value" }}
/>
</main>
);
}
関連する箇所を抜粋したペイロードは以下のように変わります。
2d:["$","div","server",{"children":"Hi there! I'm a Server Component!"},"$2e","$2f",1]
28:["$","main",null,{"children":["$2d",["$","$L31","client",{"string":"string","number":0,"boolean":true,"_null":null,"array":[1,"string",false],"object":{"key":"value"}},"$29","$30",1]]},"$29","$2c",1]
2dから始まる行にはkey
に渡した"server"
という文字列が含まれるようになりました。後ろのprops部分は変わっておらずServerComponent
に渡したpropsは残っていないことが分かります。
これを踏まえると配列の2,3,4番目の要素が順番にコンポーネント名、key、propsとなっていそうです。
これはreact/jsx-runtime
のjsx
関数に渡す引数と(順番は違いますが)同じ内容になっています。
28から始まる行を見てみます。
"client"
という文字列が出てくるようになりました。ここがClientComponent
っぽいです。
そして"client"
という文字列の後ろを見てみるとClientComponent
に渡したpropsがそのままここに出てきています!
SCからCCに渡したpropsはそのままレスポンスに含まれるということが分かりました。
つまり以下のようにSCからCCのpropsに巨大な値を渡してしまうと...?
import ClientComponent from "./client-component";
export default function Page() {
return (
<main>
<ClientComponent
superDuperLargeArray={Array.from({ length: 10000 ** 2 }, () =>
Math.random()
)}
/>
</main>
);
}
まとめ
RSCの登場でサーバーとクライアントの境界が曖昧になってきました。
今書いてるコードがサーバーだけで実行されるのか、クライアントでも実行されるのかを意識していないと思わぬ事故に繋がります。
今回起きたのは転送量の肥大化なので最悪でも請求額が膨れ上がるだけで済みますが(済みはしない)意図せず機密情報が漏れると取り返しのつかないことになります。taintObjectReference
やtaintUniqueValue
のようなAPIも実験的機能として利用できますが、結局これらの機能も正しく使わなければ意味がありません。
RSCペイロードを見ればCCに渡してるデータが分かるので、拡張機能などで大きすぎるpropsや機密情報の漏洩を検知して警告を出せそうではあります。
誰か作ってくれないかなぁ...
関連サイト
-
How to Optimize RSC Payload Size
- RSCペイロードを削減する方法について書かれたVercelのガイドラインです。
-
RSC Parser
- RSCペイロードをパースして依存関係などを見ることができます。

ちょっと株式会社(chot-inc.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion