The four limits, in the order you hit them
| Limit | Where | Symptom when exceeded |
maxAllowedContentLength | IIS request filtering (bytes, default ~28.6 MB) | HTTP 404.13 — IIS rejects before ASP.NET sees it |
maxRequestLength | ASP.NET httpRuntime (kilobytes, default 4096) | HTTP 404 or a generic error page; HttpException "Maximum request length exceeded" |
executionTimeout | ASP.NET httpRuntime (seconds, default 110) | Request killed mid-upload on a slow connection |
| Connection / idle timeouts | IIS, load balancers, reverse proxies | Upload dies at a consistent elapsed time, not a consistent percentage |
Raising them (and why that is not the answer)
<system.web>
<!-- kilobytes: 2 GB. executionTimeout in seconds -->
<httpRuntime targetFramework="4.8" maxRequestLength="2097151" executionTimeout="3600" />
</system.web>
<system.webServer>
<security>
<requestFiltering>
<!-- bytes: 2 GB -->
<requestLimits maxAllowedContentLength="2147482624" />
</requestFiltering>
</security>
</system.webServer>
These two settings use different units — maxRequestLength is in kilobytes, maxAllowedContentLength is in bytes. Setting one and not the other is the single most common cause of "I raised the limit and it still fails".
But raising them only moves the failure. A classic <asp:FileUpload> post is buffered by ASP.NET before your page executes, so a 2 GB upload means a 2 GB buffer for the lifetime of that request. Ten concurrent users and the app pool recycles. Worse, a failure at 98% starts again at 0%, and there is no progress to show the user because the page has not run yet.
The approach that actually scales: chunking
Split the file in the browser and send it as a sequence of small requests. Every request is well under every limit above, so the limits stop mattering. A failed chunk retries by itself instead of restarting the file, memory per request stays flat regardless of file size, and progress is real because the server acknowledges each piece.
<%@ Register Assembly="AjaxUploader" Namespace="AjaxUploader.Controls" TagPrefix="au" %>
<au:AjaxFileUpload ID="Uploader1" runat="server"
EnableChunkedUpload="true"
ChunkSize="5242880"
ParallelChunks="3"
MaxRetries="5"
MaxFileSize="5368709120"
OnFileUploaded="Uploader1_FileUploaded" />
With EnableChunkedUpload on and ChunkSize set, the browser sends 5 MB slices — three at a time, each retried up to five times — and the handler reassembles them server-side. Your OnFileUploaded code-behind still receives one complete file — the chunking is invisible to your application logic.
protected void Uploader1_FileUploaded(object sender, FileUploadedEventArgs e)
{
// e.FileName is attacker-controlled text. Never build a path from it.
string safeName = Guid.NewGuid().ToString("N") +
Path.GetExtension(e.FileName ?? string.Empty);
var targetFolder = Server.MapPath("~/App_Data/uploads");
Directory.CreateDirectory(targetFolder);
new UploadService().CopyFile(e.FileGuid, Path.Combine(targetFolder, safeName));
}
Choosing a chunk size
1–5 MB suits almost everything. Smaller chunks mean more round trips and more per-request overhead; larger chunks mean more work lost when one fails and more memory held per in-flight request. Two constraints worth knowing: direct-to-S3 multipart requires parts of at least 5 MB (except the last), and on high-latency mobile connections smaller chunks with a few in flight beat one large chunk.
Survive the network, not just the limits
- Retry each chunk with backoff. A transient failure on one 5 MB slice should cost seconds, not the whole upload.
- Use a stall timeout. A connection that dies without an error can leave a request open indefinitely; a watchdog that gives up on silence and retries is what turns a hang into a recovery.
- Persist state for resume across reloads, and verify it against the server before trusting it — temp directories get swept and servers get redeployed.
- Sweep abandoned chunk folders on a timer. Every interrupted upload leaves partial data; without cleanup, disk usage only ever grows.
If files are large and public: skip your server
When files live in S3, Azure Blob Storage or GCS anyway, presigned direct uploads let the browser send bytes straight to storage. IIS never touches the payload, so bandwidth, disk and request limits stop being your problem. Sign the URLs server-side, derive the object key server-side, and keep the expiry short — the signing endpoint is privileged, because anyone who can call it can write into your bucket.
Checklist
maxRequestLength (KB) and maxAllowedContentLength (bytes) both raised, and consistent with each other
executionTimeout raised, and any proxy or load-balancer idle timeout raised to match
- Chunked transfer enabled so no single request approaches those limits
- Per-chunk retry, a stall timeout, and resume verified against server state
- Server-side size cap enforced while writing, not against the declared size
- Uploads stored outside the web root under a server-generated name
- Abandoned chunk directories swept on a schedule