Why Uploading Ten Documents Took Longer Than It Needed To
A working S3 upload flow made independent documents wait for one another. Here is the small scheduling change that fixed it.
- Author
- Faisal Husain
- Published
- Reading time
- 2 min read
- Share

Users could select multiple documents, and every document eventually reached S3. Nothing was obviously broken. But the second document did not even request its upload URL until the first had finished uploading. With ten documents, that wait repeated ten times.
This was an older codebase built with substantial AI assistance. The loop was an understandable first implementation, and small-file testing made it look fine. Larger files made the accumulated waiting easier to notice.
The wait hidden in the loop
This is a simplified reconstruction, not the historical source:
async function uploadDocuments(files: File[]) {
const results = [];
for (const file of files) {
const signedUpload = await getUploadUrl(file);
const result = await uploadToS3(
file,
signedUpload
);
results.push(result);
}
return results;
}The first await is necessary: a file needs its own presigned URL before it can upload. The second await makes the next file wait for the current upload to finish. That dependency was unnecessary because the files were independent.
The timeline shows the queue. Blue is the request to our backend for a URL; amber is the upload to S3. Later files remain idle in the sequential version, while each concurrent upload begins as soon as its own URL arrives.
Where the batch waits
Four documents, one shared time scale. Dashed tracks show planned work; solid bars show elapsed work.
Sequential uploads
7.3s totalConcurrent uploads
1.9s totalExample times: sequential 7.3s = sum of signing + upload times; concurrent 1.9s = longest per-file workflow. This comparison assumes sufficient capacity and omits bandwidth contention, throttling, and browser connection limits. On a narrow screen, scroll the timelines sideways.
Each file gets its own upload URL before uploading. Sequential scheduling starts each file after the previous upload finishes. Concurrent scheduling starts all signing requests together and each upload starts after its own signing request finishes.
Keep the dependency that matters
Each file still follows “get URL → upload.” What changes is the link between files: uploading A no longer gates the URL request for B.
Which arrows are necessary?
Before: each completed upload gates the next file.
Here is the corresponding change, again simplified rather than copied from production:
async function uploadDocuments(files: File[]) {
const tasks = files.map(async (file) => {
const signedUpload = await getUploadUrl(file);
return uploadToS3(
file,
signedUpload
);
});
return Promise.all(tasks);
}Calling the async callback through map starts each file's workflow. Each callback waits for its own URL before uploading. Promise.all then waits for all those tasks; it does not start them. Results remain in input order even if uploads finish out of order.
How many files should upload at once?
The Promise.all example starts a workflow for every selected file. With ten files, that means up to ten signing requests; each upload starts when its own URL arrives, so up to ten uploads may overlap. That removes the unnecessary waiting, but more concurrency is not automatically faster: uploads share bandwidth, and the browser or service may limit connections.
For larger batches, I would consider a limit such as three or four active file workflows, starting another when one finishes. The right number depends on file sizes, network conditions, and service limits; I do not have a measured number from this application. The useful review question is both which files can run concurrently? and how many should run at once?
Thanks for reading.
Let's work together
Have a product problem worth solving?
I help teams build fast, dependable products with thoughtful interfaces and strong engineering foundations.