Skip to content
Merged
12 changes: 12 additions & 0 deletions dev/upload.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,17 @@
<vaadin-radio-button value="rejected" label="Rejected"></vaadin-radio-button>
<vaadin-radio-button value="error" label="Server error"></vaadin-radio-button>
</vaadin-radio-group>
<hr style="margin-block: 24px" />
<h3>no-auto + max-concurrent-uploads=1</h3>
<p>Test: Add multiple files, click "Start" on the first file, then try clicking "Start" on another file. The start button should hide and show 0% progress while queued.</p>
<vaadin-upload id="no-auto-upload" target="/api/fileupload" no-auto max-concurrent-uploads="1"></vaadin-upload>
<script type="module">
import { xhrCreator } from '@vaadin/upload/test/helpers.js';

const noAutoUpload = document.querySelector('#no-auto-upload');
noAutoUpload._createXhr = () => {
return xhrCreator({ size: 512, uploadTime: 5000, stepTime: 1000 })();
};
</script>
</body>
</html>
23 changes: 23 additions & 0 deletions packages/upload/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ Once installed, import the component in your application:
import '@vaadin/upload';
```

## Performance Considerations

When uploading large numbers of files, the component automatically throttles concurrent uploads to prevent browser performance degradation. By default, a maximum of 3 files are uploaded simultaneously, with additional files queued automatically.

You can customize this limit using the `max-concurrent-uploads` attribute:

```html
<!-- Limit to 5 concurrent uploads -->
<vaadin-upload max-concurrent-uploads="5"></vaadin-upload>
```

```js
// Or set it programmatically
upload.maxConcurrentUploads = 5;
```

This helps prevent:
- Browser XHR limitations (failures when uploading 2000+ files simultaneously)
- Performance degradation with hundreds of concurrent uploads
- Network congestion on slower connections

The default value of 3 balances upload performance with network resource conservation.

## Contributing

Read the [contributing guide](https://vaadin.com/docs/latest/contributing) to learn about our development process, how to propose bugfixes and improvements, and how to test your changes to Vaadin components.
Expand Down
10 changes: 10 additions & 0 deletions packages/upload/src/vaadin-upload-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,16 @@ export declare class UploadMixinClass {
*/
uploadFormat: UploadFormat;

/**
* Specifies the maximum number of files that can be uploaded simultaneously.
* This helps prevent browser performance degradation and XHR limitations when
* uploading large numbers of files. Files exceeding this limit will be queued
* and uploaded as active uploads complete.
* @attr {number} max-concurrent-uploads
* @default 3
*/
maxConcurrentUploads: number;

/**
* The object used to localize this component. To change the default
* localization, replace this with an object that provides all properties, or
Expand Down
100 changes: 99 additions & 1 deletion packages/upload/src/vaadin-upload-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,20 @@ export const UploadMixin = (superClass) =>
value: 'raw',
},

/**
* Specifies the maximum number of files that can be uploaded simultaneously.
* This helps prevent browser performance degradation and XHR limitations when
* uploading large numbers of files. Files exceeding this limit will be queued
* and uploaded as active uploads complete.
* @attr {number} max-concurrent-uploads
* @type {number}
*/
maxConcurrentUploads: {
type: Number,
value: 3,
sync: true,
},

/**
* Pass-through to input's capture attribute. Allows user to trigger device inputs
* such as camera or microphone immediately.
Expand All @@ -347,6 +361,18 @@ export const UploadMixin = (superClass) =>
_files: {
type: Array,
},

/** @private */
_uploadQueue: {
type: Array,
value: () => [],
},

/** @private */
_activeUploads: {
type: Number,
value: 0,
},
};
}

Expand Down Expand Up @@ -698,12 +724,53 @@ export const UploadMixin = (superClass) =>
Array.prototype.forEach.call(files, this._uploadFile.bind(this));
}

/**
* Process the upload queue by starting uploads for queued files
* if there is available capacity.
* @private
*/
_processQueue() {
// Process as many queued files as we have capacity for
while (this._uploadQueue.length > 0 && this._activeUploads < this.maxConcurrentUploads) {
const nextFile = this._uploadQueue.shift();
if (nextFile && !nextFile.complete && !nextFile.uploading) {
this._uploadFile(nextFile);
}
}
}

/** @private */
_uploadFile(file) {
if (file.uploading) {
return;
}

// Check if we've reached the concurrent upload limit
if (this._activeUploads >= this.maxConcurrentUploads) {
// Add to queue if not already queued
if (!this._uploadQueue.includes(file)) {
this._uploadQueue.push(file);
// Only set held = true and "Queued" status if file wasn't manually started by user.
// If user clicked "Start", held is already false and status is "0%",
// so keep those values to hide the start button and show progress.
if (file.held !== false) {
file.held = true;
file.status = this.__effectiveI18n.uploading.status.held;
}
this._renderFileList();
}
return;
}

// Remove from queue if it was queued
const queueIndex = this._uploadQueue.indexOf(file);
if (queueIndex >= 0) {
this._uploadQueue.splice(queueIndex, 1);
}

// Increment active uploads counter
this._activeUploads += 1;

const ini = Date.now();
const xhr = (file.xhr = this._createXhr());

Expand Down Expand Up @@ -745,7 +812,13 @@ export const UploadMixin = (superClass) =>
if (xhr.readyState === 4) {
clearTimeout(stalledId);
file.indeterminate = file.uploading = false;

// Decrement active uploads counter
this._activeUploads -= 1;

if (file.abort) {
// Process queue even on abort
this._processQueue();
return;
}
file.status = '';
Expand All @@ -759,6 +832,8 @@ export const UploadMixin = (superClass) =>
);

if (!evt) {
// Process queue even if event was cancelled
this._processQueue();
return;
}
if (xhr.status === 0) {
Expand All @@ -776,6 +851,9 @@ export const UploadMixin = (superClass) =>
}),
);
this._renderFileList();

// Process the queue to start the next upload
this._processQueue();
}
};

Expand Down Expand Up @@ -877,10 +955,24 @@ export const UploadMixin = (superClass) =>
);
if (evt) {
file.abort = true;

// Remove from queue if it was queued
const queueIndex = this._uploadQueue.indexOf(file);
if (queueIndex >= 0) {
this._uploadQueue.splice(queueIndex, 1);
}

// Decrement active uploads if file was uploading
if (file.uploading) {
this._activeUploads -= 1;
}

if (file.xhr) {
file.xhr.abort();
}
this._removeFile(file);
// Process the queue to start the next upload
this._processQueue();
}
}

Expand Down Expand Up @@ -998,7 +1090,13 @@ export const UploadMixin = (superClass) =>

/** @private */
_onFileStart(event) {
this._uploadFile(event.detail.file);
const file = event.detail.file;
// Mark file as started by user - hide start button and show 0% progress
file.held = false;
file.progress = 0;
file.status = '0%';
this._renderFileList();
this._uploadFile(file);
}

/** @private */
Expand Down
11 changes: 8 additions & 3 deletions packages/upload/test/adding-files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('adding files', () => {

beforeEach(async () => {
upload = fixtureSync(`<vaadin-upload></vaadin-upload>`);
upload.target = 'http://foo.com/bar';
upload.target = 'https://foo.com/bar';
upload._createXhr = xhrCreator({ size: testFileSize, uploadTime: 200, stepTime: 50 });
await nextRender();
files = createFiles(2, testFileSize, 'application/x-octet-stream');
Expand Down Expand Up @@ -332,12 +332,17 @@ describe('adding files', () => {

describe('start upload', () => {
it('should automatically start upload', () => {
upload.maxConcurrentUploads = 1;
const uploadStartSpy = sinon.spy();
upload.addEventListener('upload-start', uploadStartSpy);

files.forEach(upload._addFile.bind(upload));
expect(uploadStartSpy.calledTwice).to.be.true;
expect(upload.files[0].held).to.be.false;
// With queue behavior, only the first file starts uploading immediately
expect(uploadStartSpy.calledOnce).to.be.true;
// Files are prepended, so the first file added is at index 1
expect(upload.files[1].held).to.be.false;
// Second file (at index 0) should be held in queue
expect(upload.files[0].held).to.be.true;
});

it('should not automatically start upload when noAuto flag is set', () => {
Expand Down
Loading