#37560·React

[Flight] decodeReply, decodeReplyFromBusboy and decodeReplyFromAsyncIterable return different results for the same bytes

Author: glivterCreated Sep 10, 2026Updated Sep 16, 2026

Summary

React ships three decoders for the Server Action reply format and they return different results for the same bytes:

  • decodeReply
  • decodeReplyFromBusboy
  • decodeReplyFromAsyncIterable

Next.js selects between the first two by runtime — Edge turns the body into a web FormData and calls decodeReply, Node pipes it through busboy — and export const runtime = 'edge' is a per-route setting, so both commonly exist in one application. The same Server Action, given the same request bytes, can receive different arguments depending on where the route runs.

React version

[email protected], [email protected] (current stable). Also reproduced on 19.3.0-canary-1d34f91d-20260909 with identical results.

Steps to reproduce

npm i [email protected] [email protected] [email protected] busboy
node --conditions=react-server hunt-differential.mjs

The script builds the same logical entries as a multipart body and decodes it both ways.

Current behaviour

CASE                             decodeReply                    decodeReplyFromBusboy
plain field only                 ok:{a:1}                       ok:{a:1}
FD values then ref               ok:{a:[x=A]}                   ok:{a:[x=A]}
ref then FD values               ok:{a:[x=A]}                   ok:{a:[]}              <-- differs
two FD interleaved               ok:{x:[a=A],y:[b=B]}           ok:{x:[a=A],y:[b=B]}
blob before ref                  ok:{t:TA(2)}                   ok:{t:TA(2)}
blob after ref                   ok:{t:TA(2)}                   rej:TypeError          <-- differs

decodeReplyFromAsyncIterable disagrees on a third axis: a forward chunk reference is left pending where the other two resolve it, so the application receives a promise that never settles.

Over 400 randomly generated multipart layouts:

payloads: 400   differentials: 216  (54.0%)
   148  one decoder accepts, the other rejects
    68  BOTH accept and hand the application DIFFERENT VALUES

The 68 are the interesting ones.

Expected behaviour

Which of the three decoders happens to run should not change the arguments an application receives. Either all three accept a body and agree on its value, or all three reject it.

Why they disagree

decodeReply receives the whole FormData up front, so a reference resolves against an entry anywhere in the body.

The streaming decoders resolve as entries arrive, and the $K FormData reconstruction walks a shared cursor over the backing store (ReactFlightReplyBackingFormData.js), assuming each FormData's values arrive contiguously and immediately before its reference. decodeReplyFromBusboy maintains a linked list of pending files whose stated purpose is to make the backing FormData's insertion order match the payload's entry order, so the invariant is known and load-bearing — this is the same area as #36468.

React's own encoder always satisfies that invariant: encodeReply -> decodeReply round-trips losslessly over ten shapes I tried (single / two / three FormData, reversed, the same object twice, duplicate keys, empty, nested, mixed with a Blob, arrays of FormData). The divergence only shows up for hand-written bodies.

Impact

I am filing this as a correctness bug, not a security report, and I want to be precise about why.

A client composes the whole body, so choosing which of several results the application sees is not by itself a privilege gain — the same client could send either shape directly. There is no bypass here.

It matters where the same bytes are parsed twice by different code and the conclusions are compared or relied on: an Edge middleware that inspects an action body in front of a Node route handler, or a proxy, WAF or audit log that parses the multipart itself. That requires a specific application pattern, so I state it as a scenario rather than a demonstrated problem.

Operationally it is simpler: an action that works on Edge and 500s on Node for identical input, in 148 of 400 payloads.

Suggested fix

Either buffer in the streaming decoders until the body ends before resolving references, matching decodeReply, or have decodeReply enforce the same ordering invariant so all three reject the same bodies. The part worth removing is silently producing different answers for one input.

Note

Next.js is the consumer that selects between the decoders. I have not filed there; if it would be better coordinated on that side, say so and I will.