Why Request.Form Is Empty: FormData vs URLSearchParams in Classic ASP AJAX

The POST fired. The network tab showed a clean 200, a request body with all my fields in it, every value right there in plain sight. And Request.Form on the ASP side came back empty. No exception, no warning in the log, no 500. Just an empty collection where my form fields should have been.

If you maintain a Classic ASP backend behind any kind of modern JavaScript frontend, you will hit this, and you will lose an hour to it before you figure out the cause. It is not your data. It is not your endpoint. It is the content type.

The setup that breaks

I ran into this on a walkthrough overlay feature in our codebase for the sports SaaS that has been running on Windows, IIS, and Classic ASP for two decades. 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.asp", {
method: "POST",
body: data
});

This works perfectly against a Node endpoint, a PHP script, a Rails action, anything written this decade. 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 for you, 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=----WebKitFormBoundaryABC123

The request body is then a multipart document, with each field wrapped in boundary markers:

------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="step"
3
------WebKitFormBoundaryABC123
Content-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

Classic ASP’s Request.Form collection parses one thing: application/x-www-form-urlencoded. That is the old-school flat encoding, the key=value&key2=value2 string you get from a plain HTML form with no file upload. ASP was written for that world, and Request.Form never learned anything newer.

When ASP 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 just 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 there 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. ASP received it fine. The two sides simply do not agree on what a request body looks like, and the disagreement is silent.

This 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 the network tab 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.asp", {
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-8

That is the exact content type Request.Form has understood since the mid-1990s. The body becomes step=3&userId=8842, ASP parses it, and Request.Form("step") returns 3. No ASP 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 was 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 (an actual file upload mixed in with text fields). You have two server-side 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 fetch call but want to be explicit, build a urlencoded string and set Content-Type: application/x-www-form-urlencoded by hand. The browser will respect a header you set, and Request.Form will parse it.
  • Read the raw body with Request.BinaryRead and parse the multipart yourself. This is real work, you have to walk the boundary markers and decode each part, and it is only worth it when you genuinely need file uploads through Classic ASP. For plain text fields it is never the right trade.

When the fields are plain text, URLSearchParams is the answer. The trap is most likely to bite when someone modernizes a frontend on top of a legacy backend, which is exactly the situation a lot of long-lived ASP apps are in right now. You upgrade the JavaScript, copy a FormData snippet from current docs, and quietly break every POST to the old server without a single thing turning red.

The takeaway

When a perfect-looking POST yields an empty Request.Form on a Classic ASP endpoint, stop debugging your data and check the request’s Content-Type. If it says multipart/form-data, that is your bug. ASP only parses application/x-www-form-urlencoded. Swap FormData for URLSearchParams on the client and the fields come back. One object, one line, and the silent empty collection turns into the data you expected all along.