The POST fired. The network tab showed a clean 200, a request body with all our fields in it, every value right there in plain sight. And Request.Form on the server side came back empty. No exception, no warning in the log, no 500. Just an empty collection where our form fields should have been.
If you maintain a legacy backend behind any kind of modern JavaScript frontend, you will hit this, and you will lose an hour to it before you find the cause. Your data was fine and so was your endpoint. The content type was the problem.
The setup that breaks
We ran into this on a walkthrough overlay feature in a legacy codebase that has been running on Windows and IIS for a very long time. The frontend was modern JavaScript. The AJAX call looked like the textbook example everyone copies from MDN:
const data = new FormData();data.append("step", currentStep);data.append("userId", userId);
fetch("/your-endpoint", { method: "POST", body: data});This works against a Node endpoint, a PHP script, a Rails action, anything written recently. FormData is the obvious, modern way to build a request body. The browser handles the encoding for you. You do not set a Content-Type header, because the browser sets it, with the correct multipart boundary.
That last sentence is the whole problem.
What FormData actually sends
When you pass a FormData object as a fetch body, the browser serializes it as multipart/form-data and sets a header like:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123The request body is then a multipart document, with each field wrapped in boundary markers:
------WebKitFormBoundaryABC123Content-Disposition: form-data; name="step"
3------WebKitFormBoundaryABC123Content-Disposition: form-data; name="userId"
8842------WebKitFormBoundaryABC123--This is a completely valid HTTP request. It is also exactly how a browser submits a <form> with a file input. Modern server frameworks parse multipart bodies natively, pull out each part, and hand you a tidy key-value map. That is why it looks like it should just work.
What Request.Form actually parses
The legacy platform’s Request.Form collection parses one thing: application/x-www-form-urlencoded. That is the old flat encoding, the key=value&key2=value2 string you get from a plain HTML form with no file upload. The platform was written for that world, and Request.Form never learned anything newer.
When the server sees Content-Type: multipart/form-data, it does not parse the body into the Form collection. It does not throw. It does not log. It leaves Request.Form empty, because as far as that collection is concerned, there was no urlencoded form data to read. The raw multipart bytes are still sitting in the request stream, untouched, reachable only through Request.BinaryRead. But Request.Form("step") returns an empty string, and Request.Form.Count is zero.
So nothing errors. The browser sent a valid request and the server received it fine. The two sides just disagree about what a request body looks like, and the disagreement is silent.
That is what makes it so hard to diagnose. Every instinct says to look at the data, the field names, the endpoint path, the session, the auth. The network tab actively misleads you because the request body is right there and looks correct. The one thing it does not flag is that the receiving language predates the encoding the browser chose.
The fix is one object
Swap FormData for URLSearchParams:
const data = new URLSearchParams();data.append("step", currentStep);data.append("userId", userId);
fetch("/your-endpoint", { method: "POST", body: data});The API is nearly identical, same .append() calls, same shape. But when you pass a URLSearchParams object as a fetch body, the browser serializes it as application/x-www-form-urlencoded and sets:
Content-Type: application/x-www-form-urlencoded;charset=UTF-8That is the exact content type Request.Form has understood since the mid-1990s. The body becomes step=3&userId=8842, the server parses it, and Request.Form("step") returns 3. No server changes. No multipart parser to write. No BinaryRead plumbing. You change one constructor on the client and the legacy backend is happy.
In our case the actual fix took about seventeen minutes, and most of that was finding it, not writing it. The diff was one line.
If you would rather not touch the client
Sometimes you do not own the frontend, or it is sending multipart for a reason, such as an actual file upload mixed in with text fields. You have two options, neither as clean as the one-line client fix:
- Set the header yourself when you build the request and keep it urlencoded. If you control the
fetchcall but want to be explicit, build a urlencoded string and setContent-Type: application/x-www-form-urlencodedby hand. The browser will respect a header you set, andRequest.Formwill parse it. - Read the raw body with
Request.BinaryReadand parse the multipart yourself. This is real work: you walk the boundary markers and decode each part. It is only worth it when you need file uploads through the legacy application. For plain text fields it is never the right trade.
What to check first
The trap is most likely to bite when someone modernizes a frontend on top of a legacy backend, which is where a lot of long-lived apps are right now. You upgrade the JavaScript, copy a FormData snippet from current docs, and break every POST to the old server without a single thing turning red.
So when a perfect-looking POST yields an empty Request.Form, stop debugging your data and check the request’s Content-Type. If it says multipart/form-data, that is your bug, because Request.Form only parses application/x-www-form-urlencoded. For plain text fields, swap FormData for URLSearchParams on the client and the fields come back. If your team is modernizing a frontend on top of an older backend, test the POSTs before release, since nothing will fail visibly.