Resumable uploads from browsers and apps
A 2 GB video sent from a phone in a basement, or over hotel Wi-Fi, rarely arrives in one go. With an ordinary upload, a connection that drops at 80% means starting again from zero. A resumable upload keeps what has arrived, so the client asks how much is stored and sends only the rest: after a dropped connection, after the browser was closed, or the next morning.
MapResourcefullUploads maps endpoints that speak tus, the open protocol
for resumable uploads, over Resourcefull's upload sessions. Front ends then use an existing
tus client and write no upload code of their own:
- in a web page, Uppy, an upload widget with progress bars, pause and resume, or tus-js-client, if you build your own interface;
- in iOS and Android apps, the tus clients for each, which also resume after the app is closed and reopened;
- in desktop apps and other services, a tus client for their language.
For small files, such as profile pictures, a plain upload is simpler. Reach for resumable uploads when files are large, connections unreliable, or a proxy in front of your application limits how large a request can be.
Setting it up
using Resourcefull.AspNetCore;
builder.Services.AddResourcefull(options => options
.UseSqlServer(builder.Configuration.GetConnectionString("Files")!)
.UseExpiry()); // deletes uploads left unfinished for a day
builder.Services.AddDataProtection(); // seals the upload URLs
var app = builder.Build();
app.MapResourcefullUploads("/uploads", options =>
{
// Decides where each upload goes. Return null to refuse it.
options.ResolveAsync = async (context, request) =>
{
int claimId = int.Parse(request.Metadata.GetValueOrDefault("claimId", "0"));
if (!await claims.MayUploadAsync(context.User, claimId)) return null;
return new ResourceUploadTarget("claims", $"{claimId}/{Guid.NewGuid():N}")
{
ContentType = request.Metadata.GetValueOrDefault("filetype"),
Metadata = { ["original-name"] = request.Metadata.GetValueOrDefault("filename", "file") }
};
};
// Runs once a file is stored, to record it in the app's own database.
options.OnCompletedAsync = (context, version) =>
claims.AttachFileAsync(version.Name, version.Metadata["original-name"]);
}).RequireAuthorization();
MapResourcefullUploads maps everything tus needs at /uploads, and returns the group of endpoints, so you add
authorization, CORS or rate limits to it as to any other. Every built-in provider supports
upload sessions, so this works the same with SQL Server, PostgreSQL, SQLite, the file system, S3 and Azure Blob.
In the web page
With Uppy, the upload code is a few lines; add its stylesheets as its documentation shows:
import Uppy from "@uppy/core";
import Dashboard from "@uppy/dashboard";
import Tus from "@uppy/tus";
new Uppy({ meta: { claimId: "1042" } })
.use(Dashboard, { inline: true, target: "#claim-files" })
.use(Tus, { endpoint: "/uploads", chunkSize: 50 * 1024 * 1024 });
Uppy sends each file's name and type, as filename and filetype, along with the meta you give it. With
tus-js-client, you draw the interface yourself:
import { Upload } from "tus-js-client";
const upload = new Upload(file, {
endpoint: "/uploads",
chunkSize: 50 * 1024 * 1024,
retryDelays: [0, 3000, 10000, 30000],
metadata: { filename: file.name, filetype: file.type, claimId: "1042" },
onProgress: (sent, total) => (progress.value = sent / total),
onSuccess: () => showDone(),
onError: (error) => showError(error)
});
// Carry on with an upload of the same file that didn't finish, such as before the page was reloaded.
const previous = await upload.findPreviousUploads();
if (previous.length > 0) upload.resumeFromPreviousUpload(previous[0]);
upload.start();
Both remember unfinished uploads in the browser, so choosing the same file again carries on where it stopped.
What happens during an upload
| The user | The client sends | Resourcefull |
|---|---|---|
| Adds a 1.8 GB file | POST /uploads, with the length and the file's name and type |
Calls ResolveAsync, checks the container's limits and allowed types, starts an upload session, and answers with the upload's URL |
| Watches the progress bar | PATCH requests, one for each 50 MB chunk |
Stores each chunk as it streams in |
| Loses the connection at 41% | Nothing, until it's back | Keeps what's stored |
| Reconnects, or comes back later | HEAD on the upload's URL, then more PATCH requests |
Answers how much is stored, and carries on from there |
| Sends the last chunk | The last PATCH |
Completes the upload: hashes the content, runs the upload inspector, makes the version current, then calls OnCompletedAsync |
Until the upload completes, nothing shows the file: it isn't listed, and it can't be downloaded. Then it becomes the name's current version all at once, as a plain upload does.
When an upload is finished
tus has no call that says "done": an upload is finished when its last byte is stored. Clients take that literally.
tus-js-client, for one, retries a request that fails, and when it resumes an upload whose HEAD says every byte is
stored, it tells the user the upload succeeded. When a HEAD answers with an error, though, it gives up on that
upload and starts a new one from the beginning, unless the answer is 423 Locked, which it retries. So the endpoints
tell a client an upload finished only once its version is stored and current. When a client resumes an upload they
can't complete just now, they answer 423, so the client tries again:
| What happens | What the client is told |
|---|---|
The upload starts with a name that already has a current version, and its mode is Create |
409 Conflict, before any of the file is sent |
The upload is larger than MaxSize or the container's size limit, or its type isn't allowed |
413 Content Too Large or 415 Unsupported Media Type, before any of it is sent |
| Completing is refused: the name was taken meanwhile, the inspector refused the content, or a policy refused it | The refusal's status code. The upload is canceled, so it can't be completed later. tus-js-client retries a 409, finds the upload gone, and starts a new one, which is refused when it starts if the name is still taken |
| Completing fails for another reason, such as a database that can't be reached | 503 Service Unavailable. The upload is kept. When the client resumes, the endpoints try to complete it again, and while that still fails they answer its HEAD with 423 Locked, which the client retries |
OnCompletedAsync throws |
500 Internal Server Error, or the full length when the client's HEAD completed the upload as it resumed. The file stays stored, and the exception is logged |
| The client resumes an upload that finished, such as after the page was reloaded | The full length, so it doesn't send the file again |
OnCompletedAsync runs after the file is stored, so the file stays even when it throws, and a client that resumes
the upload is told it finished. Save to your database in a way that tolerates a missed call, such as storing the file
first and then saving your row, as Files and your own data shows.
It can also run twice for one upload, when two requests complete it at once, such as a slow completion and the
client's retry of it, so make it safe to call again for the same version.
Deciding where uploads go
ResolveAsync is required, and it's where your rules go. It gets the request and a ResourceUploadRequest: the
upload's Length, and the Metadata the client sent, decoded. The metadata comes from the client, so treat it as you
would a form field: check it, and don't let it choose a container or a name the user may not write to. Return
null to refuse the upload with 403 Forbidden, or a ResourceUploadTarget:
| Property | Default | What it does |
|---|---|---|
ContainerName, ResourceName |
(required) | Where the file is stored. A name of your own, such as a GUID, avoids clashes and odd characters from users' file names |
Mode |
Create |
Create refuses a name that has a current version, when the upload starts. Replace replaces it when the upload completes |
ContentType |
none | The version's content type. A type the container doesn't allow is refused when the upload starts |
Metadata |
empty | Metadata to store with the version, such as the file's original name |
ExpiresAt, RetainUntil, LegalHold |
none | As for any upload: see Expiring resources and Deleting safely |
The container's policies, the name rules and the upload inspector apply as to any other upload.
Limits
An upload's size is checked when it starts, before any of it is sent: against MaxSize, if you set one (the
endpoints also send it to clients in Tus-Max-Size), and against the container's MaxResourceSize and
MaxContainerSize. As content arrives, the container's limits are checked again.
No request stores more than its upload has left: a request that says it carries more is refused with 413 Content Too
Large before any of it is read, and one that sends more without saying so is cut off at the upload's length, and
refused the same way. So the endpoints lift ASP.NET Core's limit on how large a request's body can be, for their own
requests only. A server in front of your application may still limit it: IIS
(maxAllowedContentLength), nginx (client_max_body_size, 1 MB by default) or a CDN. Rather than raise those, have
clients send the file in chunks smaller than the smallest limit, with chunkSize as in the examples above. Chunks
also keep each request short, which matters behind hosts and load balancers that end long requests.
Pages on other origins
When the page is served from a different origin than the endpoints, the browser needs CORS, and tus clients need to read
headers that browsers hide by default, such as Location and Upload-Offset. WithResourcefullUploadHeaders adds
what the endpoints use to a policy:
builder.Services.AddCors(cors => cors.AddPolicy("uploads", policy => policy
.WithOrigins("https://app.example.com")
.WithResourcefullUploadHeaders()));
app.UseCors();
app.MapResourcefullUploads("/uploads", ConfigureUploads).RequireCors("uploads").RequireAuthorization();
A missing exposed header is the most common reason a tus client fails in the browser while working in tests.
Who can use an upload's URL
An upload's URL holds a token sealed with ASP.NET Core Data Protection, as signed links are. It
names the upload, so a changed or made-up URL gets 404, and it gives away no container or file names. It works only
with endpoints mapped with the same pattern string for the same store. So when an app maps two sets with patterns of
their own, such as /avatars/uploads and /documents/uploads, one set's uploads can't be finished through the
other's, whose ResolveAsync and OnCompletedAsync expect different files. The pattern is only the string passed to
MapResourcefullUploads, not a route group's prefix, so two sets mapped at /uploads in different route groups share
their URLs: give each set a pattern of its own. When the
request that started the upload was signed in, the token also holds a hash of the user's ID, from the
NameIdentifier claim, sub or the user's name, and the URL then works only for that user.
Every instance of your application must share the Data Protection keys, kept where they survive a restart, or an upload started on one instance can't be resumed on another, nor after a deploy. See Keeping links working: upload URLs need the same.
What an upload has stored is kept in the store, not in the web server's memory, so with the keys shared, any instance can take the next chunk: there's no need for sticky sessions.
Unfinished uploads
With UseExpiry, the expiry sweep deletes an upload nothing was appended to for a day, and the
endpoints say when in Upload-Expires, so a client knows whether it can still resume. Without UseExpiry, nothing
deletes unfinished uploads, and the endpoints don't send Upload-Expires. A client can also cancel an upload with
DELETE on its URL, which deletes what it stored; tus-js-client does that with upload.abort(true).
People who can't sign in
To take resumable uploads from people who can't sign in, such as customers sending claim photos, hand them an
upload link, and map the endpoints for links with MapResourcefullUploadLinks. The link then
decides where each upload goes, in place of ResolveAsync. See
Large files: resumable uploads.
Several stores
For a store added with AddKeyedResourcefull, set StoreName, and map the endpoints once for
each store that takes uploads:
app.MapResourcefullUploads("/archive/uploads", options =>
{
options.StoreName = "archive";
options.ResolveAsync = ResolveArchiveUpload;
}).RequireAuthorization();
An upload's URL works only with endpoints mapped with the same pattern for the store it was started in. Changing the pattern the endpoints are mapped at makes the URLs of unfinished uploads stop working, as changing the Data Protection keys does.
What's supported
The endpoints speak tus 1.0 with these extensions:
| Extension | What it does | Supported |
|---|---|---|
| creation | POST starts an upload |
Yes |
| creation-with-upload | The POST can carry the first chunk, saving a round trip |
Yes |
| termination | DELETE cancels an upload |
Yes |
| expiration | Upload-Expires says when an unfinished upload is deleted |
Yes, with UseExpiry |
| creation-defer-length | Starting an upload without knowing its length | No: send Upload-Length |
| checksum | A checksum for each chunk | No: HTTPS protects each chunk, and completing hashes the whole file |
| concatenation | Sending parts of one file at once, over several connections | No |
They also take X-HTTP-Method-Override, for clients that can't send PATCH or DELETE.
Telemetry
Each request's span gets resourcefull.tus, naming what it did: options, create, resume, append,
complete (a request that completed the upload) or cancel. For a named store, it also gets
resourcefull.store. The upload session calls record their own spans, and count bytes in
resourcefull.client.appended and resourcefull.client.uploaded, as in
Uploading in parts. A completion that fails for a reason other than a refusal is
logged as a warning, and an exception from OnCompletedAsync as an error, both under
Resourcefull.AspNetCore.Uploads. See Telemetry.
Troubleshooting
The client fails in the browser, but not in tests. The page is on another origin, and CORS doesn't expose the headers it reads. See Pages on other origins.
Uploads fail with 413 partway through. A server in front of your application limits request sizes. Set the
client's chunkSize below its limit. See Limits.
Every resumed upload gets 404 after a deploy. The Data Protection keys changed. Keep them where every instance can read them, and where they survive a restart. See Who can use an upload's URL.
An upload gets 409 when it starts. Its target's mode is Create, and the name already has a current version.
Choose a name of your own, such as a GUID, or set Mode = ResourceWriteMode.Replace.
Uploads get 403. ResolveAsync returned null for them.