Implement drag-and-drop HTML5 file uploads with JavaScript
A batch upload needs a result for every file, even when one request fails. Build a JavaScript uploader that lets you select or drop several files, sends them one at a time, and keeps each file’s progress and server acknowledgment visible.
Set up a local batch uploader
You need a browser, a POSIX shell such as Bash, and Node.js 24.15 or newer in the maintained 24.x
line, or Node.js 26.5 or newer in the 26.x line. There are no packages to install or build steps.
The server uses Node’s built-in Request, File, and multipart parsing APIs. Keep the .mts
extension: Node treats it as an ES module
even inside a CommonJS project.
The walkthrough was tested on Linux with Node.js 24.15.0, 26.5.0, and 26.8.1, and Chromium 145.
This example checks byte agreement and then discards the upload. It accepts any file type, including empty files, up to 5 MiB each, with at most 10 files per selection. If you only need one file with manual retries, use the single-file uploader walkthrough.
From the directory where you keep experiments, create a new folder:
mkdir html5-upload-demo
If it already exists, stop and choose a new directory name; do not overwrite an existing project.
Save the next four code blocks as index.html, styles.css, upload.js, and server.mts inside
that folder.
Set up a basic drag-and-drop form
Save as index.html. The native input stays visible and keyboard accessible.
The multiple attribute
allows several files in one selection. Choosing again replaces the queue while it is idle.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Batch file uploader</title>
<link rel="stylesheet" href="styles.css" />
<script src="upload.js" defer></script>
</head>
<body>
<main>
<h1>Upload a batch of files</h1>
<form id="upload-form" aria-label="Batch upload">
<section id="drop-zone" aria-label="Drop files">
<label for="file-input">Choose files</label>
<input id="file-input" type="file" multiple aria-describedby="file-help" />
<p id="file-help">Choose or drop up to 10 files, each no larger than 5 MiB.</p>
</section>
<button id="upload-button" type="submit" disabled>Upload batch</button>
</form>
<p id="batch-status" role="status" aria-atomic="true">Choose files to begin.</p>
<ol id="queue" aria-label="File queue"></ol>
<noscript>This uploader needs JavaScript enabled.</noscript>
</main>
</body>
</html>
Style the queue
Save as styles.css. System colors follow the browser’s light or dark theme. Long names and
checksums wrap instead of widening the page.
:root { color-scheme: light dark; }
body {
font: 1rem/1.5 system-ui, sans-serif;
max-width: 45rem;
margin: 2rem auto;
padding: 0 1rem;
color: CanvasText;
background: Canvas;
}
#drop-zone { border: 2px dashed currentColor; padding: 1rem; }
#drop-zone.drag-over { outline: 3px solid Highlight; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button { font: inherit; padding: 0.5rem; }
button { margin-top: 1rem; }
:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; }
#queue { padding-left: 1.5rem; }
#queue li { margin-block: 1rem; overflow-wrap: anywhere; }
progress { display: block; width: 100%; }
Implement drag-and-drop interactions
Save as upload.js. Each queue row shows the filename and byte size before sending; this example
does not decode files for image previews. Selection checks give immediate feedback, and the
receiver independently enforces its file and request-body limits.
We use XMLHttpRequest.upload
for request-body progress. Each bar belongs to one file and includes multipart overhead. A bar at
100% means the body was sent; the row still waits for the response. Only HTTP 200 with the expected
byte count and SHA-256 becomes an accepted result.
The queue is locked for the whole batch: disable the picker and submit button, ignore drops and additional submissions, and continue after any individual failure. Every request has a 30-second deadline, including the wait for a response. Results stay visible until the next selection; submitting the same completed queue again is disabled.
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const zone = document.getElementById('drop-zone')
const button = document.getElementById('upload-button')
const status = document.getElementById('batch-status')
const list = document.getElementById('queue')
const maxSize = 5 * 1024 * 1024
const number = new Intl.NumberFormat('en-US')
let queue = []
let busy = false
let started = false
function updateControls() {
input.disabled = busy
button.disabled = busy || started || queue.length === 0
}
function choose(files) {
if (busy || files.length === 0) return
queue = []
started = false
list.replaceChildren()
const oversized = files.find((file) => file.size > maxSize)
if (files.length > 10) {
status.textContent = 'Choose at most 10 files.'
} else if (oversized) {
status.textContent = `${oversized.name}: exceeds the 5 MiB limit. Select the batch again.`
} else {
queue = files.map((file, index) => {
const row = document.createElement('li')
const name = document.createElement('strong')
name.textContent = `${file.name} (${number.format(file.size)} bytes)`
const label = document.createElement('label')
label.htmlFor = `progress-${index}`
label.textContent = `Request body sent: ${file.name}`
const progress = document.createElement('progress')
progress.id = label.htmlFor
progress.setAttribute('aria-label', label.textContent)
progress.max = 100
progress.value = 0
const message = document.createElement('p')
message.textContent = 'Queued.'
row.append(name, label, progress, message)
list.append(row)
return { file, progress, message }
})
status.textContent = `${queue.length} files ready. Choose Upload batch to start.`
}
updateControls()
}
input.addEventListener('change', () => {
choose(Array.from(input.files))
// Keep our File objects; allow the same selection to fire change next time.
input.value = ''
})
zone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) zone.classList.add('drag-over')
})
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'))
zone.addEventListener('drop', (event) => {
event.preventDefault()
zone.classList.remove('drag-over')
choose(Array.from(event.dataTransfer.files))
})
function send(item, sha256) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) item.progress.value = (event.loaded / event.total) * 100
else item.progress.removeAttribute('value')
})
xhr.upload.addEventListener('load', () => {
item.progress.value = 100
item.message.textContent = 'Body sent. Waiting for server acknowledgment…'
})
xhr.addEventListener('load', () => {
const receipt = xhr.response
if (xhr.status === 200 && receipt?.bytes === item.file.size && receipt?.sha256 === sha256) {
item.message.textContent = `Accepted. ${number.format(receipt.bytes)} bytes; SHA-256 ${receipt.sha256}.`
resolve(true)
} else {
item.message.textContent = xhr.status === 200
? 'Failed: invalid server receipt.'
: `Failed: server returned HTTP ${xhr.status}.`
resolve(false)
}
})
xhr.addEventListener('error', () => {
item.message.textContent = 'Failed: network error. Server acceptance is unknown.'
resolve(false)
})
xhr.addEventListener('timeout', () => {
item.message.textContent = 'Failed: request timed out. Server acceptance is unknown.'
resolve(false)
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 30000
const body = new FormData()
body.append('file', item.file)
body.append('sha256', sha256)
xhr.send(body)
})
}
async function uploadBatch() {
if (busy || started || queue.length === 0) return
busy = true
started = true
updateControls()
let accepted = 0
let failed = 0
for (const item of queue) {
status.textContent = `Uploading ${item.file.name}…`
item.message.textContent = 'Preparing checksum…'
try {
const digest = await crypto.subtle.digest('SHA-256', await item.file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
item.message.textContent = 'Sending request body…'
if (await send(item, sha256)) accepted += 1
else failed += 1
} catch {
item.message.textContent = 'Failed: could not prepare or send this file.'
failed += 1
}
}
status.textContent = `Batch finished: ${accepted} accepted, ${failed} failed. Select files for a new batch.`
busy = false
updateControls()
}
form.addEventListener('submit', (event) => {
event.preventDefault()
void uploadBatch()
})
The checksum uses crypto.subtle.digest()
and reads one file into memory at a time. Serve the page from the printed loopback URL so Web
Crypto is available. Leave Content-Type unset: the browser supplies the
multipart boundary
for FormData.
Add a receiver that acknowledges each file
Save as server.mts. It serves only the three browser assets and accepts one file plus one
sha256 field per request. It reads the complete body before parsing, allowing 16 KiB of multipart
overhead beyond the 5 MiB file limit. A request that crosses that body limit receives HTTP 413.
The checksum in a successful response comes from the bytes the receiver actually read.
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 = 5 * 1024 * 1024
const maxBody = maxSize + 16 * 1024
let origin = ''
const assets = new Map<string, { body: Buffer; type: string }>()
function reply(res: ServerResponse, code: number, data: object): void {
res.writeHead(code, { '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
}
if (req.method === 'GET' && req.url === '/favicon.ico') {
res.writeHead(204).end()
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 bytes = 0
// Keep the socket available for an error response when leaving iteration early.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > maxBody) {
reply(res, 413, { error: 'Request body exceeds the limit.' })
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 > maxSize) {
reply(res, 413, { error: 'File exceeds the 5 MiB limit.' })
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, { bytes: file.size, sha256 })
}
async function main(): Promise<void> {
const portText = process.env.PORT ?? '0'
const port = Number(portText)
if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/upload.js', 'upload.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
const server = createServer((req, res) => {
void handle(req, res).catch(() => {
if (!res.destroyed && !res.writableEnded) reply(res, 500, { error: 'Unable to process upload.' })
})
})
server.requestTimeout = 35000
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolve)
})
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}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : ''
console.error(code === 'EADDRINUSE'
? 'Port is already in use. Choose another PORT or leave it unset.'
: 'Could not start uploader. Check PORT and the three browser files.')
process.exitCode = 1
})
The server binds to loopback and selects an available port when PORT is unset or zero. You can
set PORT in the server’s environment if you need a fixed port. Missing assets, invalid PORT,
or an occupied port stop startup with a nonzero exit status.
Run the batch and read its results
From the parent directory where you created html5-upload-demo, paste:
(cd html5-upload-demo && node server.mts)
Open the printed URL, such as http://127.0.0.1:49152. Keep that terminal open; Ctrl+C stops the
server and returns you to the parent directory. Restart after editing an asset, since the server
loads those files at startup.
Tab to Choose files, open the picker, and select two files with different sizes. Tab to Upload batch and activate it. Each row should show Accepted., its byte count, and a SHA-256 checksum. The final status reports two accepted files and zero failed files. Nothing was saved on the server.
Try a drop into the outlined area as a second selection. While a batch is running, its input and button are disabled, and dropping other files leaves the current rows alone. On a fast local connection a bar may jump straight to 100%; progress events are not a smooth animation or a measure of server processing.
A rejected request leaves its failed row in place and the next queued file still runs. HTTP 400 indicates malformed multipart data or wrong fields, 403 an origin or host mismatch, 413 a size limit, and 422 a checksum mismatch. A malformed or inconsistent HTTP 200 receipt also fails. If the receiver becomes unreachable or exceeds the deadline, acceptance is unknown: a missing response cannot tell you whether a server completed work. To retry, select the failed files as a new batch; every retry sends their whole contents again. Reloading forgets the queue.
Decide what belongs in a public uploader
This local receiver checks sizes and byte agreement, accepts arbitrary contents, and discards them. A checksum does not establish that a file is safe or fully decodable. The host and origin checks are not user authentication. Before retaining uploads in a public service, add authorization, content validation, a storage policy, and CSRF protection where cookie-based sessions need it; OWASP’s upload guidance explains those controls. Keep stored uploads outside the web root and handle a lost acknowledgment without creating duplicate records, for example with a server-enforced idempotency key.
Keep the visible file input, labeled progress bars, focus outlines, and batch live region when you adapt the UI. Failures have text and remain readable without depending on color. A single percentage for the whole batch would need a defined weighting policy; averaging percentages gives a tiny file as much weight as a large one.
For a richer interface, Uppy’s XHRUpload plugin supports multipart
uploads to a compatible receiver. For resumability, its Tus plugin
requires a tus-compatible server. Neither choice makes this /upload receiver resumable.
