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=----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
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-8That 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
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 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.
Related
- Response.Redirect Blew Up the Error Page. Server.Transfer Didn’t.: another ASP behavior that silently diverges from modern expectations
- Classic ASP doesn’t care that it’s a comment: the script-delimiter collision: parser behavior that surprises anyone coming from modern server frameworks
- The Datetime That SQL Server Can’t Read Back: Keep the Copy In the Database: another silent server-side mismatch between what you send and what gets stored
- Edit a label, get " back: the HTML-entity round-trip bug: a different encode/decode boundary that silently corrupts data on round-trip
- The classic-ASP bug that printed the literal word “undefined” instead of your IP: silent Classic ASP output failure with no error thrown