Resumable Uploads (tus)
A plain upload is all or nothing. A phone sends 180 MB of a 200 MB video, the train goes into a
tunnel, and the whole thing starts again from zero — on the same flaky connection that just failed.
tus is the open protocol for doing better: the client creates the upload, sends it
in PATCHes to the URL it got back, and after any failure asks with HEAD how many bytes the server
has and carries on from there. The server keeps every byte it received, including the ones from the
request that was cut off. Trim- and AOT-clean.
| GitHub | |
| Downloads |
dotnet add package Shiny.AspNet.Tususing Shiny.AspNet.Tus;
app.MapTus("/files", o =>{ o.Store = new DiskTusStore(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "uploads")); o.MaxSize = 4L * 1024 * 1024 * 1024; // Tus-Max-Size: 4 GB o.Expiration = TimeSpan.FromDays(1); // unfinished uploads are swept after a day idle o.OnUploadCompleteAsync = async ctx => { var name = ctx.Upload.Metadata.GetValueOrDefault("filename") ?? ctx.Upload.Id; await using var file = await ctx.OpenReadAsync(); // move it somewhere permanent, record it, process it... };}).RequireAuthorization();Any tus client talks to it — nothing about the server is Shiny-specific on the wire. There is no
services.Add… call: MapTus is the whole setup.
Clients
Section titled “Clients”Browser — tus-js-client (or Uppy, which uses it):
import * as tus from 'tus-js-client';
const upload = new tus.Upload(file, { endpoint: 'https://api.example.com/files', retryDelays: [0, 1000, 3000, 5000, 10000], metadata: { filename: file.name, filetype: file.type }, onProgress: (sent, total) => console.log(`${(sent / total * 100).toFixed(1)}%`), onSuccess: () => console.log('done')});
// Picks up where a previous page load left off, from the URL tus-js-client kept in localStorage.const previous = await upload.findPreviousUploads();if (previous.length) upload.resumeFromPreviousUpload(previous[0]);upload.start();.NET — TusDotNetClient, from a console app, a service or a MAUI app:
var client = new TusClient();var file = new FileInfo("clip.mp4");
var url = await client.CreateAsync("https://api.example.com/files", file, ("filename", file.Name));await client.UploadAsync(url, file, chunkSize: 5); // MB per PATCHMobile tus clients (TUSKit on iOS, tus-android-client) work the same way.
What it implements
Section titled “What it implements”tus 1.0.0 core, with every extension a client is likely to ask for:
| Extension | What it gives the client |
|---|---|
| core | HEAD for the offset, PATCH with application/offset+octet-stream at Upload-Offset |
creation |
POST with Upload-Length (and Upload-Metadata) makes an upload; Location is its URL |
creation-with-upload |
The first chunk rides along with the POST, saving a round trip |
creation-defer-length |
Upload-Defer-Length: 1 — size unknown at the start (a recording in progress); stated on a later PATCH |
termination |
DELETE removes an upload |
expiration |
Upload-Expires on responses, and a sweep that deletes abandoned uploads |
checksum |
Upload-Checksum with sha1, sha256 or md5; a mismatch is 460 and none of that PATCH is kept |
concatenation |
Upload-Concat: partial pieces uploaded in parallel, joined by final;<urls> |
OPTIONS answers discovery (Tus-Version, Tus-Extension, Tus-Max-Size,
Tus-Checksum-Algorithm) without needing Tus-Resumable; every other request must send
Tus-Resumable: 1.0.0 or it gets 412 with Tus-Version. X-HTTP-Method-Override: PATCH (or
DELETE, HEAD) on a POST to an upload is honoured, for environments that only let GET and
POST out — tus-js-client’s overridePatchMethod. The request’s method is rewritten to the one it
named, so logging and anything after the endpoint see the verb the client meant.
Errors are problem details (application/problem+json) with the status tus specifies — 409 for an
offset that does not match, 415 for the wrong content type, 413 past MaxSize or the upload’s
length, 410 for an upload that expired, 423 when another request holds it. They are written
through IProblemDetailsService, so AddProblemDetails customisations apply. A tus client reads the
status; the body is for your logs.
What happens when the connection drops
Section titled “What happens when the connection drops”This is the reason the package exists, so it is worth being precise:
- Bytes are written as they arrive, a buffer at a time, straight to the store — a request body is never held in memory, however large. The disk store flushes each chunk to the OS, so a process that is killed mid-request loses nothing it had been sent.
- A request that ends early keeps what it delivered. The client’s next
HEADreports that offset, and its nextPATCHstarts there. - A request with
Upload-Checksumthat ends early keeps nothing, because the part that arrived is a prefix of something that was hashed whole and there is nothing to check it against. The same goes for a checksum that does not match (460) and a body that runs past the upload’s length (413). - A stalled request does not lock the client out. Only one request touches an upload at a time —
that is what stops two
PATCHes interleaving. But when a phone loses signal, the server usually cannot tell yet: the old request is still waiting on a socket that will never deliver. So when the client comes back, itsHEADasks that request to stop; it keeps what it had, lets go, and theHEADanswers with the settled offset.LockReleaseTimeout(3 s) bounds the wait; set it toTimeSpan.Zeroto answer423 Lockedimmediately instead and never interrupt anything. - It survives a restart.
DiskTusStorehas no separate offset counter — the offset is the data file’s length — so a server that is restarted answers the nextHEADwith exactly the bytes on disk.
On HTTP/1.1 an abandoned request’s connection is aborted (HttpContext.Abort()) — Kestrel would
otherwise try to drain the rest of a body that may never come. On HTTP/2 and HTTP/3 only that stream
ends, so a client that resumes on the connection it already has is not cut off by its own previous
attempt.
Options
Section titled “Options”| Property | Default | Notes |
|---|---|---|
Store |
required | Where uploads live — a DiskTusStore, or your own ITusStore |
MaxSize |
null |
Largest upload, advertised as Tus-Max-Size. 413 at creation past it |
Expiration |
null |
How long an unfinished upload survives after its last write. Sliding; completed uploads never expire |
CleanupInterval |
15 min | How often expired uploads are swept |
LockReleaseTimeout |
3 s | How long a request waits for another on the same upload to let go (see above) |
AllowDeferredLength |
true |
creation-defer-length |
AllowTermination |
true |
DELETE; off answers 405 |
AllowConcatenation |
true |
Upload-Concat |
OnBeforeCreateAsync |
null |
Authorize or validate before anything is stored — see below |
OnUploadCompleteAsync |
null |
Runs once, when the last byte is stored |
TimeProvider |
system | The clock expiry runs on; replace it in tests |
Validating before creation
Section titled “Validating before creation”OnBeforeCreateAsync sees the declared length, the parsed metadata and the HttpContext (so
ctx.HttpContext.User), and can refuse the upload before a byte is stored:
o.OnBeforeCreateAsync = ctx =>{ if (ctx.Metadata.GetValueOrDefault("filetype") is not ("video/mp4" or "image/jpeg")) ctx.Reject(StatusCodes.Status415UnsupportedMediaType, "Videos and photos only.");
return Task.CompletedTask;};Upload-Metadata is key base64value pairs; TusMetadata decodes them — the indexer gives UTF-8
text, GetBytes(key) the raw bytes, and Header the original header. It is whatever the client chose
to say: a filename of ../../etc/passwd is a string, not a path to write to.
When an upload completes
Section titled “When an upload completes”OnUploadCompleteAsync runs inside the request that delivered the last byte, before the client hears
back — once per upload. Partial uploads do not raise it; the final upload they are joined into does.
The upload stays in the store afterwards; with DiskTusStore, GetFilePath(id) is where it is, so it
can be moved rather than copied, and ctx.Store.DeleteAsync(id, …) removes it once it has been taken
care of.
Expiry
Section titled “Expiry”With Expiration set, Upload-Expires goes out on creation and on every PATCH, and every write
pushes it forward — only an upload that stops making progress expires. A request for one that has
expired gets 410 Gone and it is deleted. A sweep removes the rest: on a timer that runs from
IHostApplicationLifetime.ApplicationStarted until ApplicationStopping, and nudged by request
traffic. To sweep from a job of your own instead, keep the builder MapTus returned:
var tus = app.MapTus("/files", o => { … });
// later, from a background jobvar removed = await tus.RemoveExpiredUploadsAsync();Security, CORS and the rest of the pipeline
Section titled “Security, CORS and the rest of the pipeline”MapTus returns a TusEndpointConventionBuilder — an ordinary IEndpointConventionBuilder over the
route group the seven tus routes live in — so every ASP.NET convention covers creation, HEAD,
PATCH, DELETE and the override POST alike: .RequireAuthorization(), .RequireCors(),
.RequireRateLimiting(), .WithMetadata(…). The routes are excluded from the OpenAPI document.
MapTus works on any IEndpointRouteBuilder, including a group. Upload URLs carry the whole path the
request came in on — PathBase, every outer group prefix and its route values — so
app.MapGroup("/users/{user}").MapTus("/files", …) hands out /users/alice/files/{id}.
A browser client needs CORS to allow tus’s request headers and to expose its response headers —
script cannot read Upload-Offset or Location otherwise, and the client cannot resume or even start.
WithTusHeaders() extends ASP.NET’s CorsPolicyBuilder with both:
builder.Services.AddCors(o => o.AddPolicy("uploads", p => p.WithOrigins("https://app.example.com").WithTusHeaders()));
app.UseCors();app.MapTus("/files", o => { … }).RequireCors("uploads");A policy that exposes no headers of its own (say WithOrigins(…).AllowAnyHeader().AllowAnyMethod())
has the tus headers added to Access-Control-Expose-Headers by the endpoint. A policy that exposes
headers of its own needs WithTusHeaders() (or WithExposedHeaders(TusHeaderNames.Exposed)): the
CORS middleware writes its headers after the endpoint’s, and replaces the list with the policy’s.
Upload URLs are absolute, built from the request’s scheme and Host, so behind a proxy — with
UseForwardedHeaders restoring them — the client is handed the public URL.
Writing a store
Section titled “Writing a store”ITusStore is nine members — create, get, append, set length, set expiry, concatenate, open, delete
and list-expired. The endpoint serialises requests per upload before calling in, so a store needs no
locking of its own for that. The one contract that matters is AppendAsync:
- the stream ending early is normal — keep every byte read and return the offset they reach;
- the stream throwing means this append must not count — roll back to the starting offset and let the exception out.
The endpoint produces both from the request (an interrupted body ends; a checksum mismatch throws), so
a store never has to know about either. Throw TusException with 409 for an offset that no longer
matches, and 404 for an upload that is gone.
HTTP/1.1, HTTP/2 and HTTP/3 alike — the endpoint only sees requests. Tested end to end on Kestrel over
HTTP/1.1 and cleartext HTTP/2 (including interrupted and stalled PATCHes), and on TestServer;
HTTP/3 goes through the same handler but has no end-to-end test. Not implemented:
concatenation-unfinished (a final upload is built from parts that are all complete), and GET on an
upload — read a finished file through OnUploadCompleteAsync, or serve it yourself.
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.


