Skip to content
Merged
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
87 changes: 87 additions & 0 deletions 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,48 @@ 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);
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 +807,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 +827,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 +846,9 @@ export const UploadMixin = (superClass) =>
}),
);
this._renderFileList();

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

Expand Down Expand Up @@ -877,10 +950,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
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