Resumable file uploads in Angular
In today's web applications, unreliable networks and large files can lead to frustrating user experiences during file uploads. Resumable uploads allow your users to continue their upload where they left off if a disruption occurs. In this post, we show you how to integrate the tus-js-client into your Angular app to create robust, resumable file uploads.
Why resumable uploads?
Resumable uploads tackle common issues with large files and unstable networks. By breaking a file into manageable chunks and allowing failed segments to be retried, you ensure a more reliable user experience. The standardized tus protocol, which tus-js-client implements, makes this process easier to manage and scale.
Setting up your Angular project
Start by creating a new Angular project using the Angular CLI with standalone components:
npm install -g @angular/cli
ng new resumable-upload-demo --standalone
cd resumable-upload-demo
Install the tus-js-client package with TypeScript types:
npm install tus-js-client@4.3.1
Creating an upload service
Let's create an Angular service to handle uploads using tus-js-client. This service includes proper
TypeScript types, bounded retries, and lookup of previously stored upload URLs. Save this as
upload.service.ts. The UploadOptions type is available in the pinned 4.3.1 package.
import { Injectable } from '@angular/core'
import { DetailedError, Upload, type UploadOptions } from 'tus-js-client'
export interface UploadProgress {
bytesUploaded: number
bytesTotal: number
percentage: number
}
@Injectable({
providedIn: 'root',
})
export class UploadService {
#upload: Upload | null = null
#stopping = false
async startUpload(
file: File,
endpoint: string,
onProgress: (progress: UploadProgress) => void,
onSuccess: () => void,
onError: (error: Error) => void,
headers: Record<string, string> = {},
): Promise<void> {
if (this.#upload) {
onError(new Error('Stop the current upload before starting another.'))
return
}
const options: UploadOptions = {
endpoint,
headers,
retryDelays: [0, 1000, 3000, 5000],
removeFingerprintOnSuccess: true,
metadata: {
filename: file.name,
filetype: file.type,
},
onError: (error) => {
if (this.#upload !== upload || this.#stopping) return
this.#upload = null
const status = error instanceof DetailedError ? error.originalResponse?.getStatus() : null
const message = status === 401 || status === 403
? 'Upload authorization failed. Sign in again before retrying.'
: 'Upload stopped. Check the connection, then select Upload to retry.'
onError(new Error(message, { cause: error }))
},
onProgress: (bytesUploaded: number, bytesTotal: number) => {
if (this.#upload !== upload || this.#stopping) return
onProgress({
bytesUploaded,
bytesTotal,
percentage: bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0,
})
},
onSuccess: () => {
if (this.#upload !== upload || this.#stopping) return
this.#upload = null
onSuccess()
},
}
const upload = new Upload(file, options)
this.#upload = upload
try {
const previousUploads = await upload.findPreviousUploads()
if (this.#upload !== upload || this.#stopping) return
if (previousUploads[0]) upload.resumeFromPreviousUpload(previousUploads[0])
upload.start()
} catch (error) {
if (this.#upload !== upload || this.#stopping) return
this.#upload = null
onError(new Error('Unable to prepare the upload. Please try again.', { cause: error }))
}
}
async abortUpload(): Promise<void> {
const upload = this.#upload
if (!upload) return
this.#stopping = true
await upload.abort()
this.#upload = null
this.#stopping = false
}
}
The tus-js-client API describes its
DetailedError values and retry policy. onError reports an error that will not be retried further;
it is not a promise that recovery will continue automatically. The delay array bounds consecutive
retries, and progress can reset that budget. abort() returns a promise and pauses the upload
without deleting its server resource. A rejected abort is surfaced to the caller, and further
starts remain blocked until stopping succeeds.
After a reload, the user must select the same file again. The service looks up its saved upload URL
and resumes it before starting. This depends on browser URL storage being available, the upload
not having expired, and the server authorizing that user on every request. This example chooses
the first match; applications with multiple matches or accounts should offer a choice and scope
stored records to the active account. Leave chunkSize at its default unless the server or proxy
requires a request-size limit.
Building the upload component
Save this standalone component as upload.component.ts, import it into the root component’s
imports, and add <app-upload /> to the root template. Configure /files/ as your own tus endpoint.
For a protected endpoint, bind the current session’s access token to [authToken]; do not hardcode
a credential. This component provides its own service instance so separate upload widgets do not
share an active transfer.
import { CommonModule } from '@angular/common'
import { ChangeDetectorRef, Component, Input, type OnDestroy } from '@angular/core'
import { UploadService, type UploadProgress } from './upload.service'
@Component({
selector: 'app-upload',
standalone: true,
imports: [CommonModule],
providers: [UploadService],
template: `
<div class="upload-container">
<input
type="file"
[disabled]="isUploading"
(change)="onFileSelected($event)"
[attr.aria-label]="'Choose file to upload'"
/>
<button
(click)="startUpload()"
[disabled]="!selectedFile || isUploading"
class="upload-button"
>
{{ isUploading ? 'Uploading...' : 'Upload' }}
</button>
<button (click)="stopUpload()" [disabled]="!isUploading || isStopping">Stop</button>
<div *ngIf="progress" class="progress-container">
<div class="progress-bar" [style.width.%]="progress.percentage">
{{ progress.percentage | number: '1.0-0' }}%
</div>
</div>
<div *ngIf="message" [class]="messageType" role="alert">
{{ message }}
</div>
</div>
`,
styles: [
`
.upload-container {
padding: 1rem;
}
.progress-container {
margin-top: 1rem;
background: #f0f0f0;
border-radius: 4px;
}
.progress-bar {
height: 20px;
background: #4caf50;
border-radius: 4px;
text-align: center;
color: white;
transition: width 0.3s ease;
}
.error {
color: #d32f2f;
margin-top: 1rem;
}
.success {
color: #388e3c;
margin-top: 1rem;
}
`,
],
})
export class UploadComponent implements OnDestroy {
@Input() authToken: string | null = null
selectedFile: File | null = null
progress: UploadProgress | null = null
message = ''
messageType = ''
isUploading = false
isStopping = false
constructor(private uploadService: UploadService, private changeDetector: ChangeDetectorRef) {}
onFileSelected(event: Event): void {
const input = event.target
if (input instanceof HTMLInputElement) {
this.selectedFile = input.files?.[0] ?? null
this.progress = null
this.message = ''
this.messageType = ''
}
}
async startUpload(): Promise<void> {
if (!this.selectedFile || this.isUploading) return
this.isUploading = true
this.message = ''
this.progress = null
const uploadEndpoint = '/files/'
await this.uploadService.startUpload(
this.selectedFile,
uploadEndpoint,
(progress) => {
this.progress = progress
this.changeDetector.markForCheck()
},
() => {
this.message = 'Upload completed successfully!'
this.messageType = 'success'
this.isUploading = false
this.changeDetector.markForCheck()
},
(error: Error) => {
this.message = error.message
this.messageType = 'error'
this.isUploading = false
this.changeDetector.markForCheck()
},
this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {},
)
}
async stopUpload(): Promise<void> {
this.isStopping = true
try {
await this.uploadService.abortUpload()
this.isUploading = false
this.message = 'Upload stopped. Select Upload to resume.'
this.messageType = ''
} catch {
this.message = 'Unable to stop the upload. Please try stopping it again.'
this.messageType = 'error'
} finally {
this.isStopping = false
this.changeDetector.markForCheck()
}
}
ngOnDestroy(): void {
void this.uploadService.abortUpload().catch(() => {
console.error('Unable to stop the upload during cleanup.')
})
}
}
CORS configuration
Ensure your tus server includes the following CORS headers for proper operation:
Access-Control-Allow-Origin: <your-domain>
Access-Control-Allow-Methods: POST, PATCH, HEAD, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset
Access-Control-Expose-Headers: Upload-Offset, Location, Upload-Length, Tus-Version, Tus-Resumable, Tus-Max-Size, Tus-Extension, Upload-Metadata
Best practices and considerations
-
Authentication: tus-js-client uses its own browser XHR transport, so Angular
HttpInterceptordoes not see these requests. The service passes authorization through tus’sheadersoption. For expiring tokens, obtain a current token for each request through the documentedonBeforeRequesthook, and restrict upload URLs to your trusted server. -
File validation: Add this method inside
UploadComponentand call it instartUpload()before settingisUploading:if (!this.validateFile(this.selectedFile)) return. Browser checks provide feedback; the server must enforce size and inspect actual content.
validateFile(file: File): boolean {
const maxSize = 100 * 1024 * 1024 // 100 MiB
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (file.size > maxSize) {
this.message = 'File size exceeds 100 MiB limit'
return false
}
if (!allowedTypes.includes(file.type)) {
this.message = 'Invalid file type'
return false
}
return true
}
- Error recovery: Implement automatic retry logic for network issues
- Progress monitoring: Use RxJS subjects to broadcast upload progress to other components
- Cleanup: Properly handle component destruction and abort ongoing uploads
Conclusion
Implementing resumable uploads in Angular with tus-js-client provides a robust solution for handling large files and network interruptions. The combination of TypeScript types, proper error handling, and best practices ensures a reliable upload experience for your users.
If you need a more comprehensive file handling solution, consider exploring Transloadit's API, which offers advanced features for file processing and transformation.
