---
title: 'File Upload'
source: 'https://academia.sh/en/courses/web-fundamentals-and-html/file-upload'
course: 'Web Fundamentals and HTML'
language: en
updated: '2026-08-17T18:09:31+00:00'
license: 'CC BY-SA 4.0'
---

# File Upload

The value a file field produces, the multipart submission encoding's counterpart in the body, the accept type declaration, and where the size limit is enforced.

Validation was built on text and number fields. A file field does not fit these patterns. Its
value is not a string; it carries binary content, and has its own name and its own content
type.

The default encoding defined in the Form Structure lesson — joining `name=value` pairs with
`&` — cannot carry this content. On a form with a file field, the submission encoding
changes.

## Three Attributes

```html
<form action="/measurement/upload" method="post" enctype="multipart/form-data">
  <p><label for="record">Raw record file</label>
     <input id="record" name="record" type="file" accept=".csv,text/csv" required></p>
  <p><button type="submit">Upload</button></p>
</form>
```

`enctype="multipart/form-data"` changes the submission encoding. If it is not written, the
file field sends only the **file name**; the content does not go. This is the most common
reason a form silently misbehaves: the form submits, the server gets a value, but what it gets
is a string.

The `accept` attribute announces which types should be surfaced in the file picker. Its value
can be a list of extensions, a list of content types, or a mix of both.

The `multiple` attribute allows more than one file to be selected; each file joins the entry
list as a separate entry under the same name.

## The Shape of the Body

The script below opens a server, sends the same two fields with two different encodings, and
prints the raw body the server receives. To make line endings visible, the `\r\n` sequence is
printed explicitly.

```js
// file-upload.mjs — the two submission encodings' counterpart in the body
import { createServer } from "node:http";

// The boundary string is generated randomly on every submission; to keep the
// output reproducible it is replaced with a fixed placeholder before printing.
const server = createServer((request, response) => {
  const chunks = [];
  request.on("data", (chunk) => chunks.push(chunk));
  request.on("end", () => {
    const body = Buffer.concat(chunks);
    const contentType = request.headers["content-type"];
    const boundary = contentType.match(/boundary=(.+)$/)?.[1];
    const mask = (d) => (boundary ? d.split(boundary).join("BOUNDARY") : d);
    console.log("content-type  :", mask(contentType));
    console.log("body length   :", body.length, "bytes");
    console.log("--- raw body ---");
    console.log(mask(body.toString()).replace(/\r\n/g, "\\r\\n\n"));
    console.log("===");
    response.writeHead(204).end();
  });
});

server.listen(0, "127.0.0.1", async () => {
  const url = "http://127.0.0.1:" + server.address().port + "/upload";

  // enctype="multipart/form-data"
  const formData = new FormData();
  formData.append("station", "north-slope");
  formData.append("record", new Blob(["time,temperature\n07:00,-4.2\n"], { type: "text/csv" }), "measurement.csv");
  await fetch(url, { method: "POST", body: formData });

  // enctype="application/x-www-form-urlencoded" — the same two fields, without the file content
  await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams([["station", "north-slope"], ["record", "measurement.csv"]]).toString(),
  });

  server.close();
});
```

```
content-type  : multipart/form-data; boundary=BOUNDARY
body length   : 304 bytes
--- raw body ---
--BOUNDARY\r\n
Content-Disposition: form-data; name="station"\r\n
\r\n
north-slope\r\n
--BOUNDARY\r\n
Content-Disposition: form-data; name="record"; filename="measurement.csv"\r\n
Content-Type: text/csv\r\n
\r\n
time,temperature
07:00,-4.2
\r\n
--BOUNDARY--\r\n

===
content-type  : application/x-www-form-urlencoded
body length   : 42 bytes
--- raw body ---
station=north-slope&record=measurement.csv
===
```

`BOUNDARY` in the output is a placeholder: the real boundary string is generated randomly on
every submission, and the script replaces it with a fixed piece of text before printing it. The
boundary's text is chosen by the sender; its only condition is that it must not occur anywhere
in the body. The body length here does not vary, since the generated boundary's length is
fixed.

The body's shape is this. A **boundary** string is announced in the `Content-Type` header. The
body is split into parts by this boundary; each part carries its own headers, an empty line,
and its content. The `Content-Disposition` header gives the field's name; file fields also
carry a `filename` parameter. A file part additionally has a `Content-Type` header announcing
the file's type. The body ends with the boundary followed by two hyphens.

Two basic differences are visible.

**Content is not escaped.** In multipart encoding, file content is carried as-is; no
percent-encoding is applied. The boundary string does the job of separation. A binary file can
be sent without being encoded.

**The overhead ratio is high.** For the same two fields, 304 bytes were used against 42 bytes.
Each part carries its own headers, and the boundary string is repeated in every part. This
cost shrinks proportionally as the file grows larger; but sending a form with no file field
using multipart encoding is unnecessary overhead.

The second part of the output shows the result of the wrong encoding: the `record` field
carries only the file name. The server receives a value, and there is no content.

## An Accept Type Is Not a Constraint

The `accept` attribute filters the file picker window. The filter can be removed by the user,
and submission can be made without using the document at all; the conclusion from the Built-in
Validation lesson applies here too.

Whether a file is actually of the type it declares cannot be learned from the body's
`Content-Type` header — that header too is written by the sender. The server can verify the
type only by looking at the file's **content** — the identifying byte sequences at the start of
file formats exist for exactly this purpose.

Trusting the extension is not enough either. The uploaded file name is not used directly on the
server: a name from the user can contain a path separator, overwrite an existing file, or carry
an extension the server executes. The safe behavior is for the server to generate its own name
and store the original only as metadata.

## The Field's Value Cannot Be Written From the Document

A file field differs from other fields in one more way: its value cannot be given from the
document. Even if a `value` attribute is written, it is ignored, because otherwise a document
could silently submit any file on the user's disk. The field's value is formed only by the
user's own choice.

The same reasoning also hides the file's **path**. What is opened up to the document is the
file's name, size, and type — not the directory it sits in. A user's directory structure
carries information about that user, and it is not given to the document.

One consequence is that a form cannot redisplay a file left over from a previous submission. In
a form returned due to a validation error, other fields' values can be rewritten into the
document; the file field stays empty and the user must select the file again. In multi-step
forms, this requires the file to be uploaded at an early step, held temporarily on the server,
and referred to by an id in later steps.

## The Size Limit Cannot Be Declared in the Document

There is no attribute that limits file size. This can be called not a gap but a consistent
design: a limit declared in the document could not have been enforced anyway.

The limit is enforced in two places. The server can reject a request by looking at the
`Content-Length` header before reading the whole body, and can also enforce an upper bound
while reading; this prevents a single request from filling memory. The server software's own
configuration usually also carries a request body upper bound.

If early feedback to the user is wanted, the file size can be read in the behavior layer
before submission; this is a convenience, not a constraint. The expected limit is written as
text in the document — the user should not learn that the file they chose will be rejected
only after the upload finishes.

## In the Station Document

```html
<fieldset>
  <legend>Raw record</legend>
  <p><label for="record">Measurement file</label>
     <input id="record" name="record" type="file"
            accept=".csv,text/csv" aria-describedby="record-help">
     <span id="record-help">Comma-separated value file, 5 MB maximum.</span></p>
</fieldset>
```

The form's `enctype` attribute must be changed the moment this field is added; if forgotten,
the form appears to work and the file content never arrives.

## Summary

- A form with a file field writes `enctype="multipart/form-data"`; if not written, only the
  file name is sent and the content is lost.
- A multipart body is split into parts by a boundary string; each part carries its own headers
  and the content is transferred without being escaped.
- This encoding's overhead ratio is high; it is not used on forms with no file field.
- `accept` only filters the file picker; type checking is done on the server by looking at the
  file's content, and the uploaded name is not used directly.
- The size limit cannot be declared in the document; it is enforced on the server and
  announced to the user as text.

## Next Step

At this point the form collects data, validates it, and can accept a file. One question
remains: what does the user see when submission is rejected? The browser's own message shows
only one field, and an error returned from the server arrives as a new document. The final
lesson looks at error reporting and focus management, and closes the course by building the
accessible version of this form.
