Building a custom file uploader with JavaScript and HTML
Keep the native file input and build your custom interface around it. This walkthrough gives you a framework-free JavaScript uploader with keyboard selection, drag-and-drop, progress, and retries, plus a local server that checks the received bytes against the selected file’s SHA-256 checksum.

Set up a local upload project
You need Node.js 24 or later, a browser, and a terminal. The example uses Node’s built-in Request and File APIs, so there are no packages to install. The walkthrough was tested on Linux with Node.js 24.2.0, 26.5.0, and 26.8.1, and Chromium 145 and 152.
We will upload one nonempty JPEG, PNG, or PDF at a time, up to 10 MiB. Each attempt sends the whole file as multipart form data. This keeps the browser and server small enough to run together without implementing a chunk-assembly protocol.
Run this in a POSIX shell, such as Bash, from a directory where you keep experiments:
mkdir custom-uploader &&
cd custom-uploader &&
touch index.html styles.css script.js server.ts
The command refuses an existing directory. If any step fails, stop and resolve it before continuing; choose a different new directory name if needed. Paste the next four examples into the empty files just created. Uploads will not create or overwrite files: the receiver hashes bytes in memory and discards them after responding. A rerun checks the bytes again. Keep this demo on your own machine.
Setting up the HTML structure
Save this as index.html. The labeled file input remains visible and reachable with Tab. The drop
zone is an alternative way to select a file, and the separate Upload button starts the request.
Status messages stay on screen in a polite live region.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Custom file uploader</title>
<link rel="stylesheet" href="styles.css" />
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Upload a file</h1>
<form id="upload-form" aria-label="File upload">
<section id="drop-zone" aria-label="Drop a file">
<label for="file-input">Choose a file</label>
<input id="file-input" type="file" accept="image/jpeg,image/png,application/pdf"
aria-describedby="file-help selection" />
<p id="file-help">Choose or drop one JPEG, PNG, or PDF, up to 10 MiB.</p>
</section>
<p id="selection">No file selected.</p>
<button id="upload" type="submit" disabled>Upload</button>
<button id="retry" type="button" disabled>Retry</button>
</form>
<p><label for="progress">Request body sent</label></p>
<progress id="progress" max="100" value="0"></progress>
<p id="status" role="status" aria-live="polite" aria-atomic="true">Choose a file to begin.</p>
<pre id="receipt" aria-label="Server receipt"></pre>
<noscript>This uploader needs JavaScript enabled.</noscript>
</main>
</body>
</html>
Styling the file uploader with CSS
Save this as styles.css. Style the input’s picker button and focus outline without hiding the
input. Using display: none would remove it from keyboard navigation and assistive technology;
see MDN’s file input example.
body {
font: 1rem/1.5 system-ui, sans-serif;
margin: 2rem auto;
padding: 0 1rem;
max-width: 40rem;
color: #172b4d;
background: #fff;
}
#drop-zone {
border: 2px dashed #52647c;
border-radius: 0.5rem;
padding: 1.5rem;
}
#drop-zone.dragover { background: #e8f1ff; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button {
font: inherit;
padding: 0.5rem 1rem;
margin: 0.5rem 0;
cursor: pointer;
}
:focus-visible { outline: 3px solid #075ac7; outline-offset: 3px; }
button:disabled { cursor: default; }
progress { width: 100%; }
#status { min-height: 3rem; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
Implementing JavaScript to handle file selection and upload
Save this as script.js. XMLHttpRequest exposes
upload progress events.
They measure transmission of the request body, including multipart overhead. Reaching 100% does
not mean the server accepted the file. Only an HTTP 200 response with matching size and checksum
produces the “Accepted” message.
The busy policy is explicit: disable the picker and buttons, ignore drops and additional submissions, and keep the current file until the request settles. A network error, timeout, or server error enables Retry, with at most three attempts per selection. Retries send the entire file again; no retry runs automatically. A rejected request or invalid receipt requires a new selection.
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const zone = document.getElementById('drop-zone')
const selection = document.getElementById('selection')
const upload = document.getElementById('upload')
const retry = document.getElementById('retry')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
const receipt = document.getElementById('receipt')
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const maxSize = 10 * 1024 * 1024
let selected = null
let busy = false
let attempts = 0
let retryable = false
function updateControls() {
input.disabled = busy
upload.disabled = busy || !selected || attempts > 0
retry.disabled = busy || !selected || !retryable || attempts >= 3
}
function choose(files) {
if (busy) return
selected = null
attempts = 0
retryable = false
progress.value = 0
receipt.textContent = ''
selection.textContent = 'No file selected.'
const file = files[0]
if (files.length !== 1) {
status.textContent = 'Choose exactly one file.'
} else if (!allowedTypes.includes(file.type)) {
status.textContent = 'Choose a JPEG, PNG, or PDF with a recognized MIME type.'
} else if (file.size === 0 || file.size > maxSize) {
status.textContent = 'The file must be nonempty and no larger than 10 MiB.'
} else {
selected = file
selection.textContent = file.name
status.textContent = 'Ready to upload.'
}
updateControls()
}
input.addEventListener('change', () => {
choose(Array.from(input.files))
// Retain the File ourselves so selecting the same file can fire change again.
input.value = ''
})
zone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) zone.classList.add('dragover')
})
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'))
zone.addEventListener('drop', (event) => {
event.preventDefault()
zone.classList.remove('dragover')
choose(Array.from(event.dataTransfer.files))
})
function send(file, sha256) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) progress.value = (event.loaded / event.total) * 100
})
xhr.upload.addEventListener('load', () => {
progress.value = 100
status.textContent = 'Body sent. Waiting for server acceptance…'
})
xhr.addEventListener('load', () => {
const result = xhr.response
if (xhr.status === 200 && result?.bytes === file.size && result?.sha256 === sha256) {
resolve({ ok: true, result })
} else {
resolve({
ok: false,
retryable: xhr.status >= 500,
message: xhr.status === 200
? 'Invalid server receipt.'
: `Server rejected the upload (HTTP ${xhr.status}).`,
})
}
})
xhr.addEventListener('error', () => {
resolve({ ok: false, retryable: true, message: 'Network error.' })
})
xhr.addEventListener('timeout', () => {
resolve({ ok: false, retryable: true, message: 'Request timed out.' })
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 30000
const body = new FormData()
body.append('file', file)
body.append('sha256', sha256)
xhr.send(body)
})
}
async function startUpload() {
if (busy || !selected || attempts >= 3 || (attempts > 0 && !retryable)) return
busy = true
retryable = false
attempts += 1
updateControls()
progress.value = 0
receipt.textContent = ''
status.textContent = `Preparing attempt ${attempts} of 3…`
try {
const digest = await crypto.subtle.digest('SHA-256', await selected.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
status.textContent = `Uploading ${selected.name} (attempt ${attempts} of 3)…`
const outcome = await send(selected, sha256)
if (outcome.ok) {
status.textContent = `Accepted: ${selected.name}. Size and SHA-256 match. No file was saved.`
receipt.textContent = JSON.stringify(outcome.result, null, 2)
} else {
retryable = outcome.retryable
const next = retryable && attempts < 3
? 'Choose Retry to send it again.'
: 'Select a file to start again.'
status.textContent = `${outcome.message} ${next}`
}
} catch {
status.textContent = 'Could not prepare or send this file. Select it again.'
} finally {
busy = false
updateControls()
}
}
form.addEventListener('submit', (event) => {
event.preventDefault()
void startUpload()
})
retry.addEventListener('click', () => void startUpload())
The browser computes the checksum with
crypto.subtle.digest().
This reads the small file into memory. Serve the page at the loopback URL printed by the server
below so Web Crypto is available; do not open index.html directly. Leave Content-Type unset:
FormData supplies its own multipart boundary.
Add the local receiver
Save this as server.ts. It serves only our three browser files and accepts exactly two multipart
fields: file and sha256. The body limit allows 16 KiB for multipart headers in addition to the
10 MiB file. The receiver computes its own checksum before replying.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
const maxSize = 10 * 1024 * 1024
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const assets = new Map<string, { body: Buffer; type: string }>()
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/script.js', 'script.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
let origin = ''
function reply(res: ServerResponse, status: number, data: object): void {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(data))
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
reply(res, 403, { error: 'Use the printed loopback URL.' })
return
}
const asset = assets.get(req.url ?? '')
if (req.method === 'GET' && asset) {
res.writeHead(200, { 'Content-Type': asset.type })
res.end(asset.body)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
reply(res, 404, { error: 'Not found.' })
return
}
if (req.headers.origin !== origin) {
reply(res, 403, { error: 'Use the uploader on this server.' })
return
}
const chunks: Buffer[] = []
let size = 0
// Keep the socket open long enough to return 413 when stopping iteration early.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
size += chunk.length
if (size > maxSize + 16 * 1024) {
reply(res, 413, { error: 'Request body is too large.' })
req.resume()
return
}
chunks.push(chunk)
}
let data: FormData
try {
data = await new Request(origin, {
method: 'POST',
headers: { 'Content-Type': req.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(res, 400, { error: 'Invalid multipart body.' })
return
}
const file = data.get('file')
const expected = data.get('sha256')
if ([...data.keys()].length !== 2 || !(file instanceof File) || typeof expected !== 'string') {
reply(res, 400, { error: 'Expected one file and one checksum.' })
return
}
if (file.size === 0 || file.size > maxSize) {
reply(res, 413, { error: 'File must be nonempty and no larger than 10 MiB.' })
return
}
if (!allowedTypes.includes(file.type)) {
reply(res, 415, { error: 'Unsupported declared MIME type.' })
return
}
const sha256 = createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex')
if (sha256 !== expected) {
reply(res, 422, { error: 'Checksum does not match.' })
return
}
reply(res, 200, { name: file.name, bytes: file.size, sha256 })
}
const server = createServer((req, res) => {
void handle(req, res).catch(() => {
reply(res, 500, { error: 'Unable to process the upload.' })
})
})
server.requestTimeout = 30000
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
console.log(`Open ${origin}`)
})
The accept attribute is a picker hint, not validation.
Both the browser and this receiver check the declared MIME type; neither proves that the bytes
are a valid or safe image or PDF. The checksum establishes byte agreement, not trustworthiness.
This demo has no user accounts, persistent storage, content scanning, or format decoding. It binds
to loopback and checks the browser’s origin, but those checks are not user authentication. A public
service needs its own authorization, CSRF protection for cookie-based sessions, content validation,
and storage policy.
Run it and check the result
From inside custom-uploader, run:
node server.ts
Open the printed URL, such as http://127.0.0.1:49152. The server picks an available port, which may
change on restart. It reads the browser files at startup, so restart it after editing them. Stop it
with Ctrl+C when finished.
Press Tab to focus “Choose a file,” open the picker with the keyboard, and select a small PNG. Tab to Upload and activate it. The final status should say “Accepted,” and the receipt should show its filename, byte length, and a 64-character SHA-256 value. The browser has compared that value with its own checksum; nothing has been saved on the server.
Try these failure and interaction cases too:
- Drop one file into the outlined area, then upload it. Dropping two files should ask for exactly one.
- Try an empty file, a text file, and a file larger than 10 MiB. Each should produce a persistent explanation without starting a request. Files with an empty or unrecognized MIME type are also rejected, even if their extension looks acceptable.
- Use your browser’s Network tools to slow an upload. While it is pending, the picker and buttons should be disabled, and drops should leave the active selection alone. The bar may reach 100% while the status still says it is waiting for server acceptance.
- With the page already loaded, switch the browser offline and upload. After “Network error,” go online and choose Retry. The same selected file should succeed. Keep it offline for all three attempts to see the retry limit, then reselect the same file to start a new set of attempts.
If a request returns HTTP 400, check the multipart field names; 403 means the origin or host did not match the printed URL. HTTP 413 indicates the size limit, 415 an unsupported declared MIME type, and 422 a checksum mismatch. Reaching 100% followed by one of these errors is a failed upload. The 30-second client deadline covers the request and response; increase it deliberately if you experiment with slower links.
When you need resumable uploads
Retry here means sending the whole file again while the page is open. Reloading forgets the selection and attempt count. If you add persistent storage, handle a lost success response: a retry must not create duplicate records. Use a server-enforced idempotency key for that workflow.
For large files that must continue from a previously accepted byte offset, use a tus client and server. That requires a resumable protocol on both sides; dividing a file into chunks in browser JavaScript alone does not provide it.
