An HTMX extension for resumable file uploads via the tus protocol.
Load htmx first, then htmx-ext-tus. The tus-js-client is bundled in.
<script src="https://unpkg.com/htmx.org@2"></script>
<script src="path/to/htmx-ext-tus.js"></script>npm install htmx-ext-tus tus-js-client htmx.orgimport 'htmx.org';
import 'htmx-ext-tus';Add hx-ext="tus" to a form (or ancestor) and set data-tus-endpoint to your tus server URL.
<form hx-ext="tus" data-tus-endpoint="https://tusd.example.com/files/">
<input type="file" name="upload" />
<button type="submit">Upload</button>
</form>When the form is submitted, htmx-ext-tus intercepts the request and uploads each file via tus instead. The normal htmx AJAX request is prevented.
Multiple file inputs are supported — each file gets its own tus upload:
<form hx-ext="tus" data-tus-endpoint="https://tusd.example.com/files/">
<input type="file" name="avatar" />
<input type="file" name="document" />
<button type="submit">Upload</button>
</form>To notify your server after all uploads finish, add data-tus-complete-url or a standard hx-post/hx-put attribute. htmx-ext-tus will issue an AJAX request to that URL with the upload URLs as a JSON array in the tusUploadURLs parameter.
<form hx-ext="tus"
data-tus-endpoint="https://tusd.example.com/files/"
data-tus-complete-url="/api/uploads/done">
<input type="file" name="upload" />
<button type="submit">Upload</button>
</form>You need a tus 1.0.0 server. tusd is the reference implementation; quarkus-tus is a Java one that additionally implements Checksum and Expiration.
If the tus server is on a different origin from your page — it usually is — the browser must be allowed to read the tus response headers. This is the most common cause of uploads that fail for no visible reason: the request succeeds, but the client cannot see the offset it needs to continue.
Expose at least:
Upload-Offset, Location, Upload-Length, Tus-Version, Tus-Resumable,
Tus-Extension, Tus-Max-Size, Upload-Expires, Upload-Metadata, Upload-Concat
With tusd:
tusd -cors-allow-origin 'https://your-site.example' -cors-expose-headers 'Upload-Offset,Location,Upload-Length,Tus-Version,Tus-Resumable,Tus-Extension,Tus-Max-Size,Upload-Expires,Upload-Metadata,Upload-Concat'Access-Control-Allow-Headers must also permit the request headers the client
sends: Tus-Resumable, Upload-Length, Upload-Offset, Upload-Metadata,
Upload-Concat, Upload-Checksum, Upload-Defer-Length, Content-Type and
X-Request-ID if you enable data-tus-add-request-id.
data-tus-discover reports what a server actually offers, which is worth
checking before relying on an extension. Notably tusd implements neither
Checksum nor Expiration, so data-tus-checksum will be sent and ignored,
and tus:expires will never fire against it.
Core tus 1.0.0 plus every extension reachable from a browser. All of it is built on tus-js-client's public API — the library is not forked, patched or vendored.
| Requirement | tus-js-client | htmx-ext-tus | How |
|---|---|---|---|
POST creation |
Yes | Yes | data-tus-endpoint |
HEAD offset retrieval |
Yes | Yes | automatic on resume and retry |
PATCH with Upload-Offset |
Yes | Yes | data-tus-chunk-size |
Tus-Resumable: 1.0.0 on every request |
Yes | Yes | automatic |
X-HTTP-Method-Override |
Yes | Yes | data-tus-override-patch-method |
OPTIONS capability discovery |
No | Yes | data-tus-discover |
Tus-Max-Size enforcement |
No | Yes | file refused before the upload starts |
| Extension | tus-js-client | htmx-ext-tus | How |
|---|---|---|---|
| Creation | Yes | Yes | data-tus-endpoint, data-tus-metadata |
| Creation With Upload | Yes | Yes | data-tus-upload-data-during-creation |
| Creation Defer Length | Yes | Yes | data-tus-upload-length-deferred |
| Expiration | No | Yes | tus:expires, data-tus-restart-on-expiry |
| Checksum | No | Yes | data-tus-checksum |
| Checksum Trailer | No | Not possible | browsers cannot send HTTP request trailers — check for yourself |
| Termination | Yes | Yes | data-tus-terminate, htmx.tus.terminate(url) |
| Concatenation — parallel parts of one file | Yes | Yes | data-tus-parallel |
| Concatenation — across separate files | No | Yes | data-tus-concatenate |
| Concatenation Unfinished | n/a | n/a | a server capability; reported by discovery |
Two constraints come from the browser rather than from tus or this extension.
Checksum Trailer cannot be supported. No browser exposes an API for attaching
trailers to an outgoing request — RequestInit has no trailers member and
XMLHttpRequest.send() takes only a body. A trailers API was proposed for Fetch
and removed in 2019; the
tracking issue is still open with
no implementer commitment. WebAssembly does not help, since it reaches the
network only through the same JavaScript APIs.
This costs nothing in practice. The trailer form exists so a client can hash
while transmitting a body it cannot inspect up front, but tus chunking already
buffers each PATCH body before sending it, so the digest is always computable
in advance for the plain Upload-Checksum header. Both forms give the same
end-to-end verification, and a server advertising checksum-trailer also
advertises checksum.
Resuming after a page reload needs the file re-selected. tus URL storage
persists the upload URL, not the file. A File object dies with the page and
browsers will not reopen a local path without user action, so after a refresh or
crash the user must pick the same file again; tus-js-client then matches it by
fingerprint (name, size, type, last modified) and continues from the stored
offset. Resuming within a session is seamless — see
Auto-resume.
Diagrams follow the C4 model. Sources live in
docs/diagrams/ as PlantUML and can be regenerated with:
java -jar plantuml.jar -tsvg -o . docs/diagrams/*.pumlHow htmx, this extension, tus-js-client and browser storage fit together.
The parts of the extension itself.
A standard upload, from form submit to the completion request:
Interruption, retry and resume — including why the retry decision must not
return null:
The Checksum extension, implemented by wrapping the HTTP stack because
onBeforeRequest never sees the request body:
Concatenation across separate files:
Capability discovery and expiration:
All attributes are inherited — set them on a parent element to apply to all forms within.
| Attribute | tus-js-client option | Type | Default |
|---|---|---|---|
data-tus-endpoint |
endpoint |
string | (required) |
data-tus-chunk-size |
chunkSize |
number | tus-js-client default |
data-tus-retry-delays |
retryDelays |
space/comma-separated ints | 0 1000 3000 5000 |
data-tus-parallel |
parallelUploads |
number | 1 |
data-tus-resume |
storeFingerprintForResuming |
boolean | true |
data-tus-metadata |
metadata |
key=value, ... or JSON |
— |
data-tus-headers |
headers |
key=value, ... or JSON |
— |
data-tus-upload-url |
uploadUrl |
string | — |
data-tus-upload-size |
uploadSize |
number | — |
data-tus-upload-data-during-creation |
uploadDataDuringCreation |
boolean | false |
data-tus-override-patch-method |
overridePatchMethod |
boolean | false |
data-tus-add-request-id |
addRequestId |
boolean | false |
data-tus-upload-length-deferred |
uploadLengthDeferred |
boolean | false |
data-tus-remove-fingerprint-on-success |
removeFingerprintOnSuccess |
boolean | true |
data-tus-protocol |
protocol |
string | "tus-v1" |
data-tus-parallel-upload-boundaries |
parallelUploadBoundaries |
JSON array | — |
data-tus-metadata-for-partial-uploads |
metadataForPartialUploads |
key=value, ... or JSON |
— |
data-tus-checksum |
— | sha1 | sha256 | sha384 | sha512 |
— |
data-tus-concatenate |
— | boolean | false |
data-tus-discover |
— | boolean | false |
data-tus-restart-on-expiry |
— | boolean | false |
data-tus-terminate |
— | boolean | false |
data-tus-auto-resume |
— | boolean | false |
data-tus-complete-url |
— | string | — |
data-tus-terminate— Whentrue, cleanup (element removal) sends a DELETE request to the tus server to terminate the upload, rather than just aborting locally.data-tus-auto-resume— Whentrue, the extension callsfindPreviousUploads()before starting and automatically resumes the most recent incomplete upload for the same file. Dispatches atus:resumeevent when resuming.data-tus-upload-url— Set this to resume a specific upload by URL (skips creation).data-tus-protocol— Protocol version string, e.g."tus-v1"or"ietf-draft-03".
All events bubble and include a detail object.
| Event | Detail | Cancelable | Description |
|---|---|---|---|
tus:start |
{ file, upload } |
No | Upload is starting |
tus:progress |
{ file, bytesUploaded, bytesTotal, progress, upload } |
No | Upload progress (0–1) |
tus:success |
{ file, upload, uploadURL, lastResponse } |
No | Upload completed |
tus:error |
{ file, error, upload } |
No | Upload failed |
tus:chunk-complete |
{ file, chunkSize, bytesAccepted, bytesTotal, upload } |
No | A chunk was uploaded |
tus:upload-url-available |
{ file, upload, uploadURL } |
No | Upload URL assigned by server |
tus:before-request |
{ file, upload, request } |
No | Before each HTTP request |
tus:after-response |
{ file, upload, request, response } |
No | After each HTTP response |
tus:should-retry |
{ file, upload, error, retryAttempt } |
Yes | Retry decision — preventDefault() to skip retry |
tus:resume |
{ file, upload, previousUpload } |
No | Resuming from a previous upload (auto-resume) |
tus:auto-resume-error |
{ file, upload, error } |
No | findPreviousUploads() failed (upload still starts) |
tus:expires |
{ file, upload, expires, expiresAt } |
No | Server sent Upload-Expires |
tus:restart |
{ file, upload, error } |
No | Expired upload is being restarted from zero |
tus:capabilities |
{ versions, extensions, maxSize, checksumAlgorithms, response } |
No | OPTIONS discovery succeeded |
tus:capabilities-error |
{ error } |
No | OPTIONS discovery failed (uploads still start) |
tus:concatenated |
{ uploadURL, partialURLs, response } |
No | Final concatenated upload created |
tus:concatenate-error |
{ error, partialURLs } |
No | Concatenation could not be completed |
<form hx-ext="tus" data-tus-endpoint="/upload">
<input type="file" name="file" />
<progress id="prog" value="0" max="1"></progress>
<button type="submit">Upload</button>
</form>
<script>
document.querySelector('form').addEventListener('tus:progress', (e) => {
document.getElementById('prog').value = e.detail.progress;
});
</script>For more reliable progress (especially with large chunk sizes), listen to tus:chunk-complete:
<script>
document.querySelector('form').addEventListener('tus:chunk-complete', (e) => {
const { bytesAccepted, bytesTotal } = e.detail;
console.log(`${bytesAccepted} / ${bytesTotal} bytes accepted by server`);
});
</script><script>
document.querySelector('form').addEventListener('tus:error', (e) => {
const { file, error } = e.detail;
console.error(`Upload of ${file.name} failed:`, error.message);
});
</script>Use tus:should-retry to implement custom retry logic:
<script>
document.querySelector('form').addEventListener('tus:should-retry', (e) => {
// Don't retry on 403 Forbidden
if (e.detail.error.originalResponse?.getStatus() === 403) {
e.preventDefault();
}
});
</script>Enable automatic resumption of previous uploads for the same file:
<form hx-ext="tus"
data-tus-endpoint="/upload"
data-tus-auto-resume="true">
<input type="file" name="file" />
<button type="submit">Upload</button>
</form>
<script>
document.querySelector('form').addEventListener('tus:resume', (e) => {
console.log('Resuming previous upload:', e.detail.previousUpload);
});
</script>Access the tus Upload instance via the tus:start event for full programmatic control:
<script>
document.querySelector('form').addEventListener('tus:start', (e) => {
const upload = e.detail.upload;
// upload.abort(), upload.findPreviousUploads(), etc.
});
</script>Set data-tus-checksum to have every chunk hashed with Web Crypto and sent as
an Upload-Checksum header, implementing the tus Checksum extension. The server
rejects a corrupted chunk with 460 Checksum Mismatch, and the normal retry
path takes over.
<form hx-ext="tus" hx-post="/done"
data-tus-endpoint="https://tus.example.com/files/"
data-tus-chunk-size="5242880"
data-tus-checksum="sha1">
<input type="file" name="file" />
<button type="submit">Upload</button>
</form>Supported algorithms are sha1, sha256, sha384 and sha512 — whatever Web
Crypto provides. md5 is not available in browsers. The checksum-trailer
variant of the extension is not supported and cannot be: browsers expose no API
for sending HTTP request trailers. It is also unnecessary here, since each chunk
is fully buffered before it is sent and can always be hashed up front.
data-tus-discover issues an OPTIONS request to the endpoint before uploading
and reports what the server supports. If the server advertises a Tus-Max-Size
smaller than a selected file, that file fails with tus:error instead of
starting a doomed upload.
<form hx-ext="tus" data-tus-endpoint="https://tus.example.com/files/"
data-tus-discover="true">
<input type="file" name="file" />
</form>
<script>
document.body.addEventListener('tus:capabilities', (e) => {
console.log('server supports:', e.detail.extensions);
console.log('max upload size:', e.detail.maxSize);
});
</script>Discovery never blocks an upload — if the OPTIONS request fails,
tus:capabilities-error fires and uploads proceed anyway.
Servers implementing the Expiration extension return an Upload-Expires header.
Every response carrying one fires tus:expires, so you can warn the user or
schedule a resume before the deadline.
document.body.addEventListener('tus:expires', (e) => {
console.log(`${e.detail.file.name} expires at ${e.detail.expiresAt}`);
});Add data-tus-restart-on-expiry="true" to automatically start a fresh upload
when the server reports the old one is gone (404 or 410). A tus:restart
event fires first. The restart happens at most once per file, so an upload the
server always reports as gone cannot loop.
data-tus-concatenate="true" uploads every selected file as a partial upload,
then creates a final upload joining them server-side once all parts succeed.
<form hx-ext="tus" hx-post="/done"
data-tus-endpoint="https://tus.example.com/files/"
data-tus-concatenate="true">
<input type="file" name="parts" multiple />
<button type="submit">Upload</button>
</form>tus:concatenated fires with the final upload's URL, which is also sent to the
completion request as tusFinalURL. If any part fails, no final upload is
created and tus:concatenate-error fires instead.
For splitting a single file across parallel connections, use
data-tus-parallel instead — tus-js-client drives partial/final itself in that
mode, and this extension leaves the tagging to it.
The extension exposes its API in two ways:
- ESM —
import { configure, resetConfig, activeUploads, tus } from 'htmx-ext-tus' - IIFE / script tag —
htmx.tus.configure(...),htmx.tus.activeUploads, etc.
When loaded via <script>, the API is available on the htmx.tus namespace:
<script>
// Configure function-valued options
htmx.tus.configure({ httpStack: myCustomHttpStack });
// Check tus support
if (htmx.tus.isSupported) {
console.log('tus uploads supported');
}
// Access active uploads
const uploads = htmx.tus.activeUploads.get(formElement);
</script>Set global defaults for function-valued tus options that cannot be expressed as attributes.
import { configure } from 'htmx-ext-tus';
configure({
httpStack: myCustomHttpStack,
fileReader: myCustomFileReader,
urlStorage: myCustomUrlStorage,
fingerprint: (file, options) => {
return Promise.resolve(['tus', file.name, file.size].join('-'));
},
});Accepted keys: httpStack, fileReader, urlStorage, fingerprint, metadataForPartialUploads.
Clear all global defaults previously set via configure().
import { resetConfig } from 'htmx-ext-tus';
resetConfig(); // removes all configure() optionsA WeakMap<Element, Upload[]> tracking in-progress uploads per element. Useful for programmatic abort or inspection.
import { activeUploads } from 'htmx-ext-tus';
const uploads = activeUploads.get(formElement);
if (uploads) {
uploads.forEach(u => u.abort());
}Terminate an upload on the server given its URL (tus Termination extension).
Options set via configure() are merged in.
await htmx.tus.terminate('https://tus.example.com/files/abc123');Abort every upload tracked for an element. Pass true to also send a DELETE
to the server rather than only stopping locally.
htmx.tus.abort(document.querySelector('#upload-form'), true);The HTTP stack that implements the Checksum extension, exported for advanced
use — for example wrapping a custom stack of your own. data-tus-checksum uses
this internally.
htmx.tus.configure({
httpStack: htmx.tus.createChecksumHttpStack('sha256', myCustomStack),
});The tus-js-client module is re-exported for convenience:
import { tus } from 'htmx-ext-tus';
if (tus.isSupported) {
console.log('tus uploads supported');
}
if (tus.canStoreURLs) {
console.log('URL storage available for resumable uploads');
}The unit suite mocks tus-js-client. The integration suite does not — it runs the real client against a real server:
./dev.sh test:integrationThat starts tusd in a container on a free port and runs the suite against it,
covering creation, chunked uploads, metadata, termination, capability
discovery, Tus-Max-Size enforcement, and Concatenation end to end.
Checksum and Expiration are covered against a small server in
test/integration/checksum-server.js, because tusd implements neither. It
validates digests with node:crypto — a different code path from the client's
Web Crypto — so agreement between them is evidence about the encoding rather
than one implementation agreeing with itself.
All commands run inside a Podman container — no local Node.js needed.
./dev.sh install # Install dependencies
./dev.sh build # Build dist/ bundles
./dev.sh test # Run tests
./dev.sh test:watch # Run tests in watch mode
./dev.sh dev # Build in watch mode
./dev.sh shell # Open a shell in the container0BSD — the same license as htmx and its official extensions.