Why your upload is failing
Upload failures are usually one of a dozen specific things. Find the symptom, get the cause. Most of these apply to any ASP.NET upload implementation, not just this one.
Before anything else: open the browser's network tab and look at the failing request. The status code and response body identify the problem far faster than reading code. If the request never appears at all, the problem is in the browser (script error, wrong URL); if it appears with a status, the problem is on the server or in front of it.
413 Request Entity Too Large
The single most common upload error. Something between the browser and your code has a size limit lower than the file. There are up to four such limits, and they are independent:
// 1. Kestrel / ASP.NET Core request body limit (default ~28.6 MB)
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 1_073_741_824);
// 2. Per-endpoint override
app.MapPost("/api/upload", handler).WithMetadata(
new Microsoft.AspNetCore.Http.Metadata.RequestSizeLimitAttribute(1_073_741_824));
// 3. Multipart section limit (forms)
builder.Services.Configure<FormOptions>(o => o.MultipartBodyLengthLimit = 1_073_741_824); <!-- 4. IIS / IIS Express request filtering, in web.config.
maxAllowedContentLength is in BYTES and defaults to ~30 MB -->
<system.webServer>
<security><requestFiltering>
<requestLimits maxAllowedContentLength="1073741824" />
</requestFiltering></security>
</system.webServer> On classic ASP.NET there is also httpRuntime maxRequestLength, which is in kilobytes — a genuinely confusing mismatch with maxAllowedContentLength's bytes. Set both.
The better fix: stop sending huge single requests. With chunked uploads no individual request is large, so these limits stop mattering — you only need them to exceed the chunk size. See large file uploads.
404 on the upload endpoint
Usually one of:
app.MapCoreUploadEndpoints()was never called, or is called after a terminal middleware.- The app runs under a virtual directory, so
/api/uploadis really/myapp/api/upload. Build URLs withUrl.Content("~/…")rather than hard-coding a leading slash. - The client posts to the wrong shape: the single-upload strategy posts to exactly the URL you give it, so a mismatch between
/api/uploadand/api/upload/uploadshows up as a 404. - Chunked uploads need two endpoints. If
chunkUrlis set butchunkCompleteUrlis not, chunks upload fine and then assembly 404s at the very end — a distinctive "100% then failed" signature.
400 Bad Request with an antiforgery message
ASP.NET Core validates antiforgery tokens on POST. An upload posted by JavaScript does not automatically carry one. Either send the token, or disable the check for the upload group deliberately:
// Send the token (preferred when uploads are authenticated)
<core-upload asp-antiforgery="true" />
// Or opt out for the endpoint group
builder.Services.AddCoreUpload(o => o.EnableAntiforgery = false); If you turn it off, remember that DELETE is then reachable cross-site by anyone who knows a file GUID.
CORS errors
Uploading to a different origin than the page requires CORS on the server — and uploads are never "simple" requests, so a preflight always happens. The preflight must allow your custom headers; chunked uploads send several:
app.UseCors(p => p
.WithOrigins("https://app.example.com")
.WithHeaders("X-Upload-Id", "X-Chunk-Index", "X-Chunk-Count",
"X-File-Name", "X-File-Size", "Content-Type")
.WithMethods("POST", "GET", "DELETE", "PATCH", "HEAD", "OPTIONS")
.AllowCredentials()); If you use cookies, AllowCredentials() and withCredentials: true must both be set, and the origin cannot be *. For direct-to-cloud uploads the CORS rules live on the bucket, not your server — and must expose ETag for multipart completion to work.
The upload reaches 100% and then fails
Progress reflects bytes leaving the browser, so 100% means "sent", not "stored". A failure right at the end points at what happens after the last byte:
- Chunked: assembly failed. Check the completion request (missing endpoint, a chunk that never arrived, disk full, or the temp folder swept between chunks).
- Direct-to-cloud: the completion call failed — commonly the browser could not read
ETagbecause the bucket's CORS config does not expose it. - Proxy timeout: the request completed but a reverse proxy gave up waiting for the response while your handler was still writing to disk or scanning.
Uploads hang, then time out
A stalled upload is usually a proxy or load balancer with a short idle timeout, buffering enabled, or a body-size cap that silently drops the connection. Nginx's client_max_body_size and proxy_read_timeout are the usual suspects; on IIS, check connectionTimeout. A stall watchdog helps the client notice and retry instead of waiting forever:
CoreUpload.create('#uploader', {
chunked: true,
stallTimeout: 30000, // no progress for 30s -> abort and retry the chunk
retries: 3
}); "The uploader doesn't appear" / nothing happens on click
- A JavaScript error earlier on the page stopped execution — check the console first.
- Initialization ran before the library loaded. If your script runs immediately and the library is at the end of the body, the constructor is not defined yet; defer to
DOMContentLoaded. - A Content Security Policy is blocking the script or its Web Worker. Workers created from blob URLs need
worker-src blob:. - The target element does not exist when
create()runs (a typo'd id, or a container rendered later by a framework).
Files upload but are missing afterwards
Uploads land in a temp store first and are cleaned up on a timer. If you do not move or persist a file, it will disappear — by design. Handle the completion event and move the file into your permanent storage, or mark it persisted. A file "vanishing after an hour" is nearly always the temp-expiry sweep doing its job.
Everything works locally and fails in production
The classic differences: the app pool identity cannot write to the upload folder; the load balancer sends chunks of one upload to different servers (use shared storage for the chunk directory, or sticky sessions); HTTPS termination changes the URLs the client builds; and IIS request filtering is present in production but not in your local Kestrel run.