[Flight] Outline and dedupe repeated strings (#37147)
GitHub Commits - Example: React

[Flight] Outline and dedupe repeated strings (#37147)

Commit dc631ef [Flight] Outline and dedupe repeated strings (#37147) Flight has two ways to write a string: 1. Small ones are inlined into the JSON model. 2. Large ones (>= 1024 chars) are outlined into a binary text row so they don't get double-encoded and double-parsed. Neither is ever deduplicated. That's most visible in client reference metadata, where a route repeats the same bundler chunk URLs across every client reference (for example, vercel/next.js#95559). This PR adds a dedupe map for strings inside import metadata. A string is written once into its own row, and every occurrence is a reference to it. ## How it works When we're about to write a string in import metadata at least as long as the threshold, we look it up in the request's map: - If it's not in the map: Emit a row containing the string, store that row's reference in the map, and write the reference here. - If it is: Write the reference. So every string goes on the wire once, and every occurrence costs a few bytes. An earlier version waited for the second occurrence before outlining, which is the right default for arbitrary strings where most never repeat (it's what #27537 does for objects). Import metadata is the opposite case: a chunk is listed by every client module that lives in it, so a chunk name that appears once is the exception. In the bench app's three routes, every chunk string at least 16 characters long appears more than 20 times and none appears once. Outlining on first sight saves the inline copy, and a string that never repeats costs 7 bytes more than inlining it. Import metadata needs its own map and its own queue. The client resolves a client reference as soon as it parses the import row, and import chunks flush ahead of model rows, so the string row has to be in the same queue to arrive first. The client needs no protocol changes. It already resolves $N references, and a row holding a string resolves to that string. It does get a check that import metadata never blocks on a row that hasn't arrived, which is the other end of the queue ordering above, and getOutlinedModel stops allocating a path array for references without one. Model strings are left alone. An earlier version of this PR deduped them too, but we're not going to do this now per review (maybe in a follow-up). Metadata on the debug channel is also left alone: it's a separate serialization path, and deduping across the two would make the main payload depend on whether a debug channel is attached. ## Threshold The trigger is 16, low compared to what a model-side threshold would want, because import metadata is repetitive but its parts are short. How much this saves depends on how many client references share a chunk list, so measuring one string on its own is misleading. For a chunk path of realistic length today: | references sharing the chunk | before | after | |---|---|---| | 1 | 80 B | 87 B | | 2 | 160 B | 122 B | | 3 | 240 B | 157 B | | 5 | 401 B | 228 B | | 10 | 813 B | 416 B | | 40 | 3273 B | 1526 B | | 80 | 6594 B | 3047 B | (Import and string rows only.) It costs 7 bytes at 1 reference and wins from 2. Chunk paths in this app are 47 characters, so a threshold of 48 or higher saves nothing at all here. That's why it's 16: picking a number just under one bundler's path length gives you something that quietly stops working on the next bundler. The map is bounded by the combined length of the strings it holds, 32 KiB. Once the budget is spent, new strings are written inline every time while strings already outlined keep deduping. That makes the savings depend on the order strings are first seen: a shared chunk URL first encountered after 32 KiB of unique module ids won't be deduped. That's main's behavior, so it's a missed win rather than a regression, but a manifest-heavy dev route could hit it. ## Byte measurements Three routes of a Next.js app, serial requests: | route | Flight | document | document (gzip) | |---|---|---|---| | /dashboard | โˆ’48.4% (710.1 โ†’ 366.5 KB) | โˆ’34.3% | โˆ’7.8% | | /docs | โˆ’5.8% (555.2 โ†’ 523.1 KB) | โˆ’5.0% | โˆ’0.7% | | /blog | โˆ’5.4% (878.8 โ†’ 831.7 KB) | โˆ’4.4% | โˆ’1.7% | The difference between the routes is how many client references each one has. On /dashboard the import rows shrink from 388.0 KB to about 32 KB with the row count unchanged at 114, because every client reference repeats the same 49 chunk URLs. gzip already collapses repeated strings, so โˆ’48.4% raw is only โˆ’7.8% compressed. The bytes still have to be escaped, encoded and copied before they reach the compressor, which is where most of the speedup below comes from. ## Speed measurements Benchmarked end-to-end through a Next.js app on Vercel Sandbox VMs (x86 Xeon), 16 boots, paired ABBA within each boot, boot as the unit of replication. Base is the merge-base with main, eafeac09; candidate is the current head, e0b4614c. | cell | effect | 95% CI | p | |---|---|---|---| | /dashboard serial req/s | +16.9% | ยฑ1.7 | Verification - Both arms' payloads for /dashboard were parsed and their $-references resolved recursively, then deep-compared: the resolved models are identical. The 49 extra model rows are exactly the 49 distinct chunk URLs. All 114 import rows match after resolution. - Arms fingerprint distinctly (a898f40a7bbd vs 87fb4b7ba15e), so the two builds are genuinely different. - Build fingerprints differ between arms (04440a11435d vs 43d09027ce58) and the arm version strings carry the expected shas. - Per-boot deltas are printed by the harness; on /dashboard serial req/s all 16 boots are positive (range +11.2% to +22.9%). - The bench fixture sets a deployment id, so every chunk URL carries a ?dpl= query param that exactly doubles its length (74 chars vs 37). An app without one would see roughly half the absolute byte saving on this route. The CPU wins that come from string identity rather than byte count should degrade less than proportionally, but that wasn't measured. - Not measured: payloads that exceed the 32 KiB tracking budget, and whether 16 is optimal rather than merely low enough. --------- Co-authored-by: Claude Opus 5 1 parent 675a29c commit dc631ef 4 files changed Lines changed: 505 additions & 12 deletions File tree - packages - react-client/src - react-server-dom-webpack/src/tests - react-server/src - scripts/error-codes | Original file line number | Diff line number | Diff line change | | |---|---|---|---| | ||| 2173 | 2173 | | | 2174 | 2174 | | | 2175 | 2175 | | | | 2176 | + | | | 2177 | + | | | 2178 | + | | | 2179 | + | | 2176 | 2180 | | | 2177 | 2181 | | | 2178 | 2182 | | | 2179 | 2183 | | | 2180 | 2184 | | | 2181 | 2185 | | | 2182 | 2186 | | | 2183 | | - | | 2184 | | - | | | 2187 | + | | | 2188 | + | | | 2189 | + | | | 2190 | + | | 2185 | 2191 | | | 2186 | 2192 | | | 2187 | 2193 | | | | ||| 3246 | 3252 | | | 3247 | 3253 | | | 3248 | 3254 | | | 3249 | | - | | 3250 | | - | | 3251 | | - | | 3252 | | - | | | 3255 | + | | | 3256 | + | | | 3257 | + | | | 3258 | + | | | 3259 | + | | | 3260 | + | | | 3261 | + | | | 3262 | + | | | 3263 | + | | | 3264 | + | | | 3265 | + | | | 3266 | + | | | 3267 | + | | | 3268 | + | | | 3269 | + | | | 3270 | + | | 3253 | 3271 | | | 3254 | 3272 | | | 3255 | 3273 | | | | Lines changed: 287 additions & 0 deletions | Original file line number | Diff line number | Diff line change | | |---|---|---|---| | ||| 1107 | 1107 | | | 1108 | 1108 | | | 1109 | 1109 | | | | 1110 | + | | | 1111 | + | | | 1112 | + | | | 1113 | + | | | 1114 | + | | | 1115 | + | | | 1116 | + | | | 1117 | + | | | 1118 | + | | | 1119 | + | | | 1120 | + | | | 1121 | + | | | 1122 | + | | | 1123 | + | | | 1124 | + | | | 1125 | + | | | 1126 | + | | | 1127 | + | | | 1128 | + | | | 1129 | + | | | 1130 | + | | | 1131 | + | | | 1132 | + | | | 1133 | + | | | 1134 | + | | | 1135 | + | | | 1136 | + | | | 1137 | + | | | 1138 | + | | | 1139 | + | | | 1140 | + | | | 1141 | + | | | 1142 | + | | | 1143 | + | | | 1144 | + | | | 1145 | + | | | 1146 | + | | | 1147 | + | | | 1148 | + | | | 1149 | + | | | 1150 | + | | | 1151 | + | | | 1152 | + | | | 1153 | + | | | 1154 | + | | | 1155 | + | | | 1156 | + | | | 1157 | + | | | 1158 | + | | | 1159 | + | | | 1160 | + | | | 1161 | + | | | 1162 | + | | | 1163 | + | | | 1164 | + | | | 1165 | + | | | 1166 | + | | | 1167 | + | | | 1168 | + | | | 1169 | + | | | 1170 | + | | | 1171 | + | | | 1172 | + | | | 1173 | + | | | 1174 | + | | | 1175 | + | | | 1176 | + | | | 1177 | + | | | 1178 | + | | | 1179 | + | | | 1180 | + | | | 1181 | + | | | 1182 | + | | | 1183 | + | | | 1184 | + | | | 1185 | + | | | 1186 | + | | | 1187 | + | | | 1188 | + | | | 1189 | + | | | 1190 | + | | | 1191 | + | | | 1192 | + | | | 1193 | + | | | 1194 | + | | | 1195 | + | | | 1196 | + | | | 1197 | + | | | 1198 | + | | | 1199 | + | | | 1200 | + | | | 1201 | + | | | 1202 | + | | | 1203 | + | | | 1204 | + | | | 1205 | + | | | 1206 | + | | | 1207 | + | | | 1208 | + | | | 1209 | + | | | 1210 | + | | | 1211 | + | | | 1212 | + | | | 1213 | + | | | 1214 | + | | | 1215 | + | | | 1216 | + | | | 1217 | + | | | 1218 | + | | | 1219 | + | | | 1220 | + | | | 1221 | + | | | 1222 | + | | | 1223 | + | | | 1224 | + | | | 1225 | + | | | 1226 | + | | | 1227 | + | | | 1228 | + | | | 1229 | + | | | 1230 | + | | | 1231 | + | | | 1232 | + | | | 1233 | + | | | 1234 | + | | | 1235 | + | | | 1236 | + | | | 1237 | + | | | 1238 | + | | | 1239 | + | | | 1240 | + | | | 1241 | + | | | 1242 | + | | | 1243 | + | | | 1244 | + | | | 1245 | + | | | 1246 | + | | | 1247 | + | | | 1248 | + | | | 1249 | + | | | 1250 | + | | | 1251 | + | | | 1252 | + | | | 1253 | + | | | 1254 | + | | | 1255 | + | | | 1256 | + | | | 1257 | + | | | 1258 | + | | | 1259 | + | | | 1260 | + | | | 1261 | + | | | 1262 | + | | | 1263 | + | | | 1264 | + | | | 1265 | + | | | 1266 | + | | | 1267 | + | | | 1268 | + | | | 1269 | + | | | 1270 | + | | | 1271 | + | | | 1272 | + | | | 1273 | + | | | 1274 | + | | | 1275 | + | | | 1276 | + | | | 1277 | + | | | 1278 | + | | | 1279 | + | | | 1280 | + | | |

Comments

No comments yet. Start the discussion.