Angular's dependency injection and RxJS give you a solid foundation for integrating Resumable.js, but the combination requires specific patterns to work cleanly. Resumable.js uses a callback-based event model. Angular components consume observables. Bridging those two worlds means wrapping Resumable.js in an injectable service that translates events into streams, manages the instance lifecycle, and exposes a clean API that any component can inject.
This guide builds that service, wires it to a component with progress tracking and pause/resume controls, and covers the cleanup patterns that prevent memory leaks in single-page navigation.
Service Architecture
The core idea: a single UploadService owns the Resumable instance, exposes upload state as RxJS observables, and provides methods for control flow. Components never touch Resumable.js directly.
// upload.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { map, scan } from 'rxjs/operators';
import Resumable from 'resumablejs';
export interface UploadFile {
uniqueIdentifier: string;
fileName: string;
size: number;
progress: number;
status: 'pending' | 'uploading' | 'complete' | 'error';
}
export type UploadStatus = 'idle' | 'uploading' | 'paused' | 'complete' | 'error';
@Injectable()
export class UploadService implements OnDestroy {
private resumable: Resumable | null = null;
private filesSubject = new BehaviorSubject<UploadFile[]>([]);
private statusSubject = new BehaviorSubject<UploadStatus>('idle');
private progressSubject = new BehaviorSubject<number>(0);
private errorSubject = new Subject<{ fileName: string; message: string }>();
files$ = this.filesSubject.asObservable();
status$ = this.statusSubject.asObservable();
progress$ = this.progressSubject.asObservable();
errors$ = this.errorSubject.asObservable();
init(config: {
target: string;
browseElement: HTMLElement;
dropElement?: HTMLElement;
chunkSize?: number;
simultaneousUploads?: number;
testChunks?: boolean;
}): void {
this.destroy();
this.resumable = new Resumable({
target: config.target,
chunkSize: config.chunkSize || 5 * 1024 * 1024,
simultaneousUploads: config.simultaneousUploads || 3,
testChunks: config.testChunks ?? true,
});
this.resumable.assignBrowse(config.browseElement);
if (config.dropElement) {
this.resumable.assignDrop(config.dropElement);
}
this.bindEvents();
}
private bindEvents(): void {
if (!this.resumable) return;
this.resumable.on('fileAdded', () => {
this.syncFiles();
});
this.resumable.on('fileProgress', () => {
this.syncFiles();
this.progressSubject.next(
Math.floor(this.resumable!.progress() * 100)
);
});
this.resumable.on('fileSuccess', (file) => {
this.syncFiles();
if (this.resumable!.progress() === 1) {
this.statusSubject.next('complete');
}
});
this.resumable.on('fileError', (file, message) => {
this.errorSubject.next({
fileName: file.fileName,
message: message || 'Upload failed',
});
this.syncFiles();
});
this.resumable.on('uploadStart', () => {
this.statusSubject.next('uploading');
});
this.resumable.on('pause', () => {
this.statusSubject.next('paused');
});
}
private syncFiles(): void {
if (!this.resumable) return;
const mapped = this.resumable.files.map((f) => ({
uniqueIdentifier: f.uniqueIdentifier,
fileName: f.fileName,
size: f.size,
progress: Math.floor(f.progress() * 100),
status: f.isComplete()
? 'complete' as const
: f.error
? 'error' as const
: f.isUploading()
? 'uploading' as const
: 'pending' as const,
}));
this.filesSubject.next(mapped);
}
upload(): void { this.resumable?.upload(); }
pause(): void { this.resumable?.pause(); }
resume(): void { this.resumable?.upload(); }
cancel(): void {
this.resumable?.cancel();
this.filesSubject.next([]);
this.statusSubject.next('idle');
this.progressSubject.next(0);
}
removeFile(uniqueIdentifier: string): void {
const file = this.resumable?.files.find(
(f) => f.uniqueIdentifier === uniqueIdentifier
);
if (file) {
this.resumable!.removeFile(file);
this.syncFiles();
}
}
private destroy(): void {
if (this.resumable) {
this.resumable.cancel();
this.resumable = null;
}
}
ngOnDestroy(): void {
this.destroy();
this.filesSubject.complete();
this.statusSubject.complete();
this.progressSubject.complete();
this.errorSubject.complete();
}
}
The service uses BehaviorSubject for state that always has a current value (files, status, progress) and a plain Subject for errors, which are discrete events. Every Resumable.js callback calls syncFiles() to re-map the internal file array to plain objects and push the new snapshot. This avoids exposing Resumable's mutable file objects to Angular's change detection.
Providing the service at the component level (not root) is important. If the service is a singleton at root, every upload component in the app shares the same Resumable instance. Instead, provide it per component:
@Component({
selector: 'app-uploader',
providers: [UploadService],
// ...
})
This way, each <app-uploader> instance gets its own service, its own Resumable instance, and its own lifecycle.
Component Integration
The component injects the service, initializes it after view init (when the DOM elements are available), and subscribes to the observable streams.
// uploader.component.ts
import {
Component, AfterViewInit, ViewChild, ElementRef, Input
} from '@angular/core';
import { UploadService, UploadFile, UploadStatus } from './upload.service';
import { Observable } from 'rxjs';
@Component({
selector: 'app-uploader',
providers: [UploadService],
template: `
<div #dropZone class="drop-zone">
<p>Drag files here or</p>
<button #browseBtn type="button">Browse</button>
</div>
<div *ngIf="(files$ | async) as files">
<div *ngFor="let file of files" class="file-row">
<span>{{ file.fileName }}</span>
<div class="progress-bar">
<div class="fill" [style.width.%]="file.progress"></div>
</div>
<span>{{ file.progress }}%</span>
<button (click)="uploadService.removeFile(file.uniqueIdentifier)">×</button>
</div>
</div>
<div class="controls" *ngIf="(files$ | async)?.length">
<button
*ngIf="(status$ | async) === 'idle' || (status$ | async) === 'paused'"
(click)="uploadService.upload()">
{{ (status$ | async) === 'paused' ? 'Resume' : 'Upload' }}
</button>
<button
*ngIf="(status$ | async) === 'uploading'"
(click)="uploadService.pause()">
Pause
</button>
<button (click)="uploadService.cancel()">Cancel</button>
<span>Overall: {{ progress$ | async }}%</span>
</div>
`,
})
export class UploaderComponent implements AfterViewInit {
@ViewChild('browseBtn', { static: true }) browseBtn!: ElementRef;
@ViewChild('dropZone', { static: true }) dropZone!: ElementRef;
@Input() target = '/api/upload';
@Input() chunkSize = 5 * 1024 * 1024;
files$: Observable<UploadFile[]>;
status$: Observable<UploadStatus>;
progress$: Observable<number>;
constructor(public uploadService: UploadService) {
this.files$ = this.uploadService.files$;
this.status$ = this.uploadService.status$;
this.progress$ = this.uploadService.progress$;
}
ngAfterViewInit(): void {
this.uploadService.init({
target: this.target,
browseElement: this.browseBtn.nativeElement,
dropElement: this.dropZone.nativeElement,
chunkSize: this.chunkSize,
});
}
}
The async pipe handles subscriptions and unsubscriptions automatically. No manual subscribe() calls, no teardown logic in the component. Angular's template binding does the rest.
RxJS Patterns for Upload State
The basic observable setup above covers most cases, but RxJS opens up more sophisticated patterns when you need them.
Aggregated error log — use scan to accumulate errors over time rather than reacting to one at a time:
allErrors$ = this.uploadService.errors$.pipe(
scan((acc, err) => [...acc, err], [] as { fileName: string; message: string }[])
);
Debounced progress — if you're updating a backend status endpoint or analytics, debounce the progress stream so you're not sending hundreds of requests:
import { debounceTime } from 'rxjs/operators';
debouncedProgress$ = this.uploadService.progress$.pipe(
debounceTime(500)
);
Completion notification — filter the status stream for the 'complete' transition and trigger a side effect:
import { filter, take } from 'rxjs/operators';
this.uploadService.status$.pipe(
filter(s => s === 'complete'),
take(1)
).subscribe(() => {
this.notificationService.show('All files uploaded');
});
These patterns compose naturally because the underlying state is exposed as observables. This is where Angular's RxJS-first approach pays off compared to imperative callback handling.
Chunk Size Configuration
Hard-coding chunk size works for prototypes, but production apps benefit from adjusting it based on file size. A reasonable heuristic: smaller chunks for smaller files (where per-request overhead matters less), larger chunks for big files (where reducing total request count improves throughput). See the Vue component example for a similar approach in a different framework.
function getChunkSize(fileSize: number): number {
if (fileSize < 10 * 1024 * 1024) return 1 * 1024 * 1024; // < 10 MB: 1 MB chunks
if (fileSize < 100 * 1024 * 1024) return 5 * 1024 * 1024; // < 100 MB: 5 MB chunks
return 10 * 1024 * 1024; // 100 MB+: 10 MB chunks
}
You can wire this into the service's fileAdded handler using the configuration options to adjust chunk size per file, or set a sensible default and let the server-side constraints — covered in the cloud storage comparison — determine the upper bound.
Cleanup and Navigation Guards
The service's ngOnDestroy handles cleanup when the component is destroyed, but you might also want to warn users before they navigate away during an active upload. Angular's route guards handle this:
// can-deactivate.guard.ts
import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { UploaderComponent } from './uploader.component';
@Injectable({ providedIn: 'root' })
export class UploadGuard implements CanDeactivate<UploaderComponent> {
canDeactivate(component: UploaderComponent): boolean {
// Check if upload is in progress via the service
// This is a simplified example; in practice you'd
// check the current status value
return confirm('Upload in progress. Leave this page?');
}
}
Without this, navigating away cancels the upload silently. The user gets no feedback, and the server is left with partial chunk data that will need cleanup.
When to Use This Pattern
This service-based architecture is a good fit when uploads are a core feature of your app — document management, media libraries, form submissions with large attachments. For one-off upload forms, the full service layer might be more structure than you need. The basic uploader example shows a lighter approach that works fine for simpler cases.
The events reference documents every Resumable.js event used in the service bindings. The methods reference covers the control flow methods — upload(), pause(), cancel(), removeFile() — and their exact behavior during different upload states. If you're comparing framework approaches, the React integration covers the hooks-based equivalent of this service pattern.
