Upload files with a Web Worker and a local receiver
A Web Worker can run file processing away from your page’s main thread and report upload progress through messages. This example connects one dedicated worker to a runnable local receiver: select a file, upload it in sequential chunks, and download the file the receiver actually accepted.
Choose what belongs in a worker
An ordinary asynchronous upload does not need a worker. Use one when your pipeline also needs file analysis or transformations; workers can make requests, but cannot update the page’s DOM. The page renders their messages instead. See using Web Workers.
Here, hashing a small file illustrates a processing step. Web Crypto is itself asynchronous, so this example makes no claim that the worker increases upload speed or saves total memory. For several files, the separate worker pool and streams tutorial covers queuing and incremental stream reads. We will keep this page to one file and one worker.
Use Node.js 24.15.0 or a newer maintained release, Corepack with Yarn 4, Bash, and a browser with
module workers, Web Crypto, and AbortSignal.timeout(). The complete example was tested on Linux
with Node.js 24.15.0 and 26.8.1 and Chromium 145. Node 24 is LTS and Node 26 is Current as of
October 2, 2026; choose a current patch from the Node release list.
If Corepack is missing, follow Yarn’s installation instructions
before continuing.
Create the local project and page
Paste this into Bash. It refuses an existing directory, isolates the Yarn project with its own
lockfile, and returns to your original directory if installation fails. A failed installation may
leave the new demo directory behind; inspect it and choose a fresh name before retrying. All later
files belong inside worker-upload-demo.
if (
mkdir worker-upload-demo &&
cd worker-upload-demo &&
printf '%s\n' '{"name":"worker-upload-demo","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'globalFolder: .yarn/global' 'npmRegistryServer: https://registry.npmjs.org' > .yarnrc.yml &&
touch yarn.lock &&
mkdir src &&
corepack yarn add --dev --exact vite@8.3.1 typescript@6.0.3 @types/node@26.6.3
); then
cd worker-upload-demo
else
printf '%s\n' 'Setup failed; inspect the new directory before retrying.' >&2
false
fi
Save this as index.html. The receiver will serve the built page and upload routes from the same
loopback origin. Open its printed HTTP URL, rather than opening this file directly.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dedicated worker upload</title>
</head>
<body>
<main>
<h1>Dedicated worker upload</h1>
<label for="fileInput">File to upload, up to 16 MiB</label>
<input type="file" id="fileInput" />
<button type="button" id="uploadBtn">Upload</button>
<button type="button" id="cancelBtn" disabled>Cancel</button>
<label id="progressLabel" for="uploadProgress">Upload progress</label>
<progress id="uploadProgress" aria-labelledby="progressLabel" value="0" max="100"></progress>
<p id="status" role="status">Choose a file.</p>
<output id="checksum" aria-label="Receiver SHA-256"></output>
<p><a id="download" hidden download="received.bin">Download received file</a></p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Define and check the message contract
Save src/worker-types.ts. A completion message includes the receiver’s checksum and download
URL, so the page can show an observable result.
export interface WorkerMessage {
file: File
}
export type WorkerResponse =
| { type: 'processed' }
| { type: 'progress'; percent: number }
| { type: 'confirming' }
| { type: 'complete'; sha256: string; url: string }
| { type: 'error'; message: string }
Save tsconfig.json for the page:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/main.ts", "src/worker-types.ts"]
}
Save tsconfig.worker.json. Checking workers separately avoids combining DOM and worker globals.
Vite builds the module worker, while TypeScript
checks its types.
{
"extends": "./tsconfig.json",
"compilerOptions": { "lib": ["ES2022", "WebWorker"] },
"include": ["src/upload.worker.ts", "src/worker-types.ts"]
}
Save tsconfig.server.json for the Node receiver:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["server.mts"]
}
Send one chunk at a time from the worker
Save src/upload.worker.ts. Files up to 5 MiB are hashed before sending; larger files skip
whole-file hashing. Each request sends a raw Blob, waits for an HTTP acknowledgment, and then
advances to the next offset. Empty files need no chunk requests, but still require finalization.
xhr.upload supplies
transfer progress. Those events do not confirm server acceptance. The bar stays below 100% until
the final request succeeds, including when all bytes have already left the browser.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_FILE_SIZE = 16 * 1024 * 1024
function send(message: WorkerResponse): void {
self.postMessage(message)
}
function uploadChunk(chunk: Blob, url: string, onProgress: (ratio: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total)
}
xhr.open('PUT', url)
xhr.timeout = 60_000
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error('Chunk rejected'))
}
xhr.onerror = () => reject(new Error('Upload network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new Error('Upload canceled'))
xhr.send(chunk)
})
}
async function run({ file }: WorkerMessage): Promise<void> {
if (file.size > MAX_FILE_SIZE) throw new Error('File exceeds the demo limit')
let expectedHash: string | undefined
if (file.size <= CHUNK_SIZE) {
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
expectedHash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
send({ type: 'processed' })
}
const created = await fetch(`/uploads?size=${file.size}`, {
method: 'POST', signal: AbortSignal.timeout(60_000),
})
if (!created.ok) throw new Error('Could not create upload')
const entry: unknown = await created.json()
if (!entry || typeof entry !== 'object' || !('id' in entry)
|| typeof entry.id !== 'string' || !/^[0-9a-f-]{36}$/.test(entry.id)) {
throw new Error('Invalid upload ID')
}
const url = `/uploads/${entry.id}`
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
const chunk = file.slice(start, start + CHUNK_SIZE)
await uploadChunk(chunk, `${url}?offset=${start}`, (ratio) => {
const percent = (start + ratio * chunk.size) / file.size * 100
send({ type: 'progress', percent: Math.min(99, percent) })
})
}
send({ type: 'confirming' })
const completed = await fetch(`${url}/complete`, {
method: 'POST', signal: AbortSignal.timeout(60_000),
})
if (!completed.ok) throw new Error('Finalization failed')
const result: unknown = await completed.json()
if (!result || typeof result !== 'object' || !('sha256' in result) || !('size' in result)
|| typeof result.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(result.sha256)
|| result.size !== file.size || (expectedHash !== undefined && result.sha256 !== expectedHash)) {
throw new Error('Invalid completion acknowledgment')
}
send({ type: 'complete', sha256: result.sha256, url })
}
self.onmessage = (event: MessageEvent<WorkerMessage>) => {
run(event.data).catch(() => send({ type: 'error', message: 'Upload failed. Please try again.' }))
}
Render progress and release the worker
Save src/main.ts. The selected File is captured once, and disabled controls prevent overlapping
uploads. Every terminal path terminates the worker.
The identity check also ignores a queued message from a worker that has already been canceled.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
const fileInput = document.getElementById('fileInput')
const uploadBtn = document.getElementById('uploadBtn')
const cancelBtn = document.getElementById('cancelBtn')
const uploadProgress = document.getElementById('uploadProgress')
const statusElement = document.getElementById('status')
const checksum = document.getElementById('checksum')
const download = document.getElementById('download')
if (!(fileInput instanceof HTMLInputElement) || !(uploadBtn instanceof HTMLButtonElement)
|| !(cancelBtn instanceof HTMLButtonElement) || !(uploadProgress instanceof HTMLProgressElement)
|| !(statusElement instanceof HTMLElement) || !(checksum instanceof HTMLOutputElement)
|| !(download instanceof HTMLAnchorElement)) {
throw new Error('Missing upload controls')
}
let worker: Worker | null = null
const finish = (message: string): void => {
worker?.terminate()
worker = null
uploadBtn.disabled = false
fileInput.disabled = false
cancelBtn.disabled = true
statusElement.textContent = message
}
uploadBtn.addEventListener('click', () => {
if (worker !== null) return
const file = fileInput.files?.[0]
uploadProgress.value = 0
checksum.value = ''
download.hidden = true
download.removeAttribute('href')
if (!file) {
statusElement.textContent = 'Please select a file.'
return
}
if (file.size > 16 * 1024 * 1024) {
statusElement.textContent = 'Choose a file of at most 16 MiB.'
return
}
uploadBtn.disabled = true
fileInput.disabled = true
cancelBtn.disabled = false
statusElement.textContent = 'Preparing upload.'
try {
const current = new Worker(new URL('./upload.worker.ts', import.meta.url), { type: 'module' })
worker = current
current.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (worker !== current) return
const response = event.data
switch (response.type) {
case 'processed':
statusElement.textContent = 'File hashed. Uploading.'
break
case 'progress':
uploadProgress.value = response.percent
statusElement.textContent = `Uploading: ${Math.round(response.percent)}%`
break
case 'confirming':
statusElement.textContent = 'Confirming upload.'
break
case 'complete':
checksum.value = response.sha256
download.href = response.url
download.hidden = false
uploadProgress.value = 100
finish('Upload complete.')
break
case 'error':
finish(response.message)
break
}
}
current.onerror = (event) => {
if (worker !== current) return
event.preventDefault()
finish('The upload worker failed. Please try again.')
}
current.onmessageerror = () => {
if (worker === current) finish('Could not read the upload worker response.')
}
current.postMessage({ file } satisfies WorkerMessage)
} catch {
finish('Could not start the upload worker.')
}
})
cancelBtn.addEventListener('click', () => finish('Upload canceled.'))
window.addEventListener('pagehide', () => finish('Upload stopped.'))
Run the compatible local receiver
Save server.mts. Node runs this ES module with native TypeScript stripping.
The receiver creates a generated-ID file, accepts chunks at the next expected offset, and exposes a
download only after checking the completed length and computing SHA-256. It limits each chunk’s
payload to 5 MiB, appends chunks to disk, and streams the stored file when calculating its checksum.
This is a localhost teaching protocol with a 16 MiB file limit. It has no authentication, retry,
resume, expiration, or overall disk quota. Files remain in received/; restarting loses the
in-memory upload registry and download URLs. After stopping the demo, remove files you no longer
need from that directory. The client’s filename is never used as a storage path.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { appendFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { parseArgs } from 'node:util'
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_FILE_SIZE = 16 * 1024 * 1024
const receivedRoot = new URL('./received/', import.meta.url)
interface Upload {
path: URL
size: number
received: number
complete: boolean
busy: boolean
}
const uploads = new Map<string, Upload>()
function reply(response: ServerResponse, status: number, value: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(value))
}
async function readBody(request: IncomingMessage, limit: number): Promise<Buffer> {
const parts: Buffer[] = []
let length = 0
for await (const part of request) {
if (!(part instanceof Buffer)) throw new Error('Unexpected request body')
length += part.length
if (length > limit) throw new Error('Request body exceeds its limit')
parts.push(part)
}
return Buffer.concat(parts, length)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const url = new URL(request.url ?? '/', 'http://localhost')
if (request.method === 'GET' && (url.pathname === '/' || /^\/assets\/[\w.-]+\.(js|css)$/.test(url.pathname))) {
const path = url.pathname === '/' ? '/index.html' : url.pathname
const data = await readFile(new URL(`./dist${path}`, import.meta.url))
response.writeHead(200, {
'Content-Type': path.endsWith('.html') ? 'text/html' : path.endsWith('.css') ? 'text/css' : 'text/javascript',
})
response.end(data)
return
}
if (request.method === 'POST' && url.pathname === '/uploads') {
const sizeText = url.searchParams.get('size')
const size = sizeText !== null && /^\d+$/.test(sizeText) ? Number(sizeText) : NaN
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_FILE_SIZE) {
reply(response, 400, { error: 'Invalid file size' })
return
}
await readBody(request, 0)
const id = randomUUID()
const path = new URL(`${id}.bin`, receivedRoot)
await writeFile(path, Buffer.alloc(0), { flag: 'wx' })
uploads.set(id, { path, size, received: 0, complete: false, busy: false })
reply(response, 201, { id })
return
}
const match = /^\/uploads\/([0-9a-f-]{36})(\/complete)?$/.exec(url.pathname)
const upload = match?.[1] !== undefined ? uploads.get(match[1]) : undefined
if (!upload) {
reply(response, 404, { error: 'Upload not found' })
return
}
if (request.method === 'GET' && !match?.[2] && upload.complete) {
response.writeHead(200, { 'Content-Type': 'application/octet-stream' })
createReadStream(upload.path).on('error', () => response.destroy()).pipe(response)
return
}
if (upload.busy || upload.complete) {
reply(response, 409, { error: 'Upload is busy or already complete' })
return
}
upload.busy = true
try {
if (request.method === 'PUT' && !match?.[2]) {
const offsetText = url.searchParams.get('offset')
const offset = offsetText !== null && /^\d+$/.test(offsetText) ? Number(offsetText) : NaN
const expected = Math.min(CHUNK_SIZE, upload.size - upload.received)
if (offset !== upload.received || expected <= 0) {
reply(response, 409, { error: 'Unexpected chunk offset' })
return
}
const data = await readBody(request, expected)
if (data.length !== expected) {
reply(response, 400, { error: 'Unexpected chunk length' })
return
}
await appendFile(upload.path, data)
upload.received += data.length
reply(response, 204, null)
return
}
if (request.method === 'POST' && match?.[2] === '/complete') {
await readBody(request, 0)
if (upload.received !== upload.size || (await stat(upload.path)).size !== upload.size) {
reply(response, 409, { error: 'Upload is incomplete' })
return
}
const hash = createHash('sha256')
for await (const part of createReadStream(upload.path)) hash.update(part)
upload.complete = true
reply(response, 200, { size: upload.size, sha256: hash.digest('hex') })
return
}
reply(response, 405, { error: 'Method not allowed' })
} finally {
upload.busy = false
}
}
async function main(): Promise<void> {
const { values } = parseArgs({ options: { port: { type: 'string', default: '0' } } })
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
await readFile(new URL('./dist/index.html', import.meta.url))
await mkdir(receivedRoot, { recursive: true })
const server = createServer((request, response) => {
handle(request, response).catch(() => {
if (!response.headersSent) reply(response, 500, { error: 'Request failed' })
else response.destroy()
})
})
server.requestTimeout = 60_000
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 listening address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Receiver startup failed')
process.exitCode = 1
})
From the demo directory, paste this block to check all three environments, build the page and worker,
and run the receiver. The && chain prevents a failed build from starting an older build. Port zero
asks the operating system for an available port; open the URL printed by the receiver. Use Ctrl+C
in this terminal to stop it. To choose a fixed port, replace --port 0 with, for example,
--port 8000; an occupied port makes startup fail.
corepack yarn tsc --project tsconfig.json &&
corepack yarn tsc --project tsconfig.worker.json &&
corepack yarn tsc --project tsconfig.server.json &&
corepack yarn vite build &&
node server.mts --port 0
Choose a small file and click Upload. When Upload complete. appears, use Download received file and compare the downloaded bytes with your original. The displayed SHA-256 describes the receiver’s file; only the small-file branch also compares it with a client-side hash. Completion confirms this local upload contract, without claiming that the file passed malware scanning or another processing pipeline.
Try a file larger than 5 MiB to exercise multiple requests and a short final chunk. Click Cancel while an upload is pending, then start another upload. Cancellation stops the worker and its client activity; accepted bytes can remain on the receiver, and a finalization already accepted by the server cannot be undone by canceling its response. A fresh attempt creates a new ID. Splitting a file into chunks does not make this protocol resumable.
For resumable transfers, Uppy’s Tus plugin uses the tus protocol with a compatible tus server. That is a different server contract from this demo.
