The Art of Non-Blocking Downloads in Angular: Because Nobody Likes a Frozen UI 🧊

Or: How I Learned to Stop Worrying and Love Asynchronous File Downloads

The Problem: When Downloads Attack Your UX

Picture this: You’re happily clicking around your Angular app, feeling productive, when suddenly you click “Download Report” and… FREEZE. Your entire UI becomes as responsive as a Windows 95 machine trying to run Crysis. The culprit? Your backend is sending a massive byte array, and your frontend is sitting there like a deer in headlights, waiting for the download to complete.

Fear not, fellow developer! We’re about to embark on a journey to create a non-blocking download system that’s smoother than a jazz saxophone and more satisfying than popping bubble wrap.

The Solution: A Queue-Based Download System That Actually Works

Our hero today is the DocumentQueueService – a service so elegant, it makes other services weep with envy. This bad boy handles downloads in the background while your users continue their digital adventures, blissfully unaware of the byte-crunching happening behind the scenes.

The Star of the Show: DocumentQueueService

Let’s dive into the main attraction — our download queue service that’s about to make your users’ lives infinitely better:

import { Injectable, OnDestroy } from '@angular/core';
import {
BehaviorSubject,
catchError,
concatMap,
finalize,
map,
Observable,
of,
Subject,
tap,
} from 'rxjs';
export interface DocumentDownloadTask {
id: string;
name: string;
type: string;
fileName: string;
status: 'pending' | 'downloading' | 'completed' | 'failed';
progress: number;
data?: any;
blob?: Blob;
error?: string;
createdAt: Date;
completedAt?: Date;
}
type DownloadHandler = (
data: any,
fileName: string,
) => Observable<{ blob: Blob; fileName: string }>;
@Injectable({ providedIn: 'root' })
export class DocumentQueueService implements OnDestroy {
private downloadQueue: DocumentDownloadTask[] = [];
private queueSubject = new BehaviorSubject<DocumentDownloadTask[]>([]);
private downloadSubject = new Subject<{
taskId: string;
type: string;
fileName: string;
data: any;
}>();
private maxConcurrentDownloads = 5;
private activeDownloads = 0;
private downloadHandlers: Record<string, DownloadHandler> = {};
constructor() {
this.initializeDownloadProcessor();
}
get downloadTasks$(): Observable<DocumentDownloadTask[]> {
return this.queueSubject.asObservable();
}
ngOnDestroy(): void {
this.downloadSubject.complete();
this.queueSubject.complete();
}

What’s happening here? We’re setting up our download task interface (think of it as a download’s resume), and our service that’s going to manage everything like a very organized personal assistant. The BehaviorSubject keeps track of our download queue, while the Subject handles new download requests. We’re limiting ourselves to 5 concurrent downloads because we’re civilized people, not animals.

Adding Tasks: The “Please Download This” Function

addDownloadTask(
type: string,
name: string,
fileName: string,
data: any,
): string {
const taskId = this.generateTaskId();
const task: DocumentDownloadTask = {
id: taskId,
name,
type,
fileName,
status: 'pending',
progress: 0,
createdAt: new Date(),
data,
};
this.downloadQueue.push(task);
this.queueSubject.next([...this.downloadQueue]);
this.downloadSubject.next({ taskId, type, fileName, data });
return taskId;
}
registerDownloadHandler(type: string, handler: DownloadHandler): void {
this.downloadHandlers[type] = handler;
}
removeTask(taskId: string): void {
this.downloadQueue = this.downloadQueue.filter(
(task) => task.id !== taskId,
);
this.queueSubject.next([...this.downloadQueue]);
}
clearCompletedTasks(): void {
this.downloadQueue = this.downloadQueue.filter(
(task) => task.status !== 'completed' && task.status !== 'failed',
);
this.queueSubject.next([...this.downloadQueue]);
}
getTaskObservable(
taskId: string,
): Observable<DocumentDownloadTask | undefined> {
return this.queueSubject.pipe(
map((tasks) => tasks.find((task) => task.id === taskId)),
);
}

The magic explained: When you call addDownloadTask(), it’s like putting a sticky note on your fridge that says “Download this thing later.” The task gets a unique ID (because we’re not savages), gets added to the queue, and immediately starts its journey through our download pipeline. The registerDownloadHandler function is where you teach the service how to handle different types of downloads – think of it as providing specific instructions for different file types.

The Download Processing Engine: Where the Magic Happens

private initializeDownloadProcessor(): void {
this.downloadSubject
.pipe(
concatMap(({ taskId, type, fileName, data }) =>
this.waitForAvailableSlot().pipe(
concatMap(() => this.processDownload(taskId, type, fileName, data)),
),
),
)
.subscribe();
}
private waitForAvailableSlot(): Observable<void> {
return new Observable<void>((subscriber) => {
const checkSlot = () => {
if (this.activeDownloads < this.maxConcurrentDownloads) {
this.activeDownloads += 1;
subscriber.next();
subscriber.complete();
} else {
setTimeout(checkSlot, 100);
}
};
checkSlot();
});
}

Here’s where it gets spicy: The initializeDownloadProcessor is like having a very patient bouncer at a club – it makes sure only the allowed number of downloads are happening at once. The waitForAvailableSlot function is beautifully simple: if there’s room, go ahead; if not, wait 100ms and check again. It’s like waiting for a table at a restaurant, but with less hangry people.

The Heavy Lifting: Processing Downloads

private processDownload(
taskId: string,
type: string,
fileName: string,
data: any,
): Observable<void> {
this.updateTaskStatus(taskId, 'downloading', 0);
let progress = 0;
const simulateProgress = () => {
if (progress < 95) {
progress += Math.floor(Math.random() * 20) + 20;
if (progress > 95) progress = 95;
this.updateTaskProgress(taskId, progress);
setTimeout(simulateProgress, 200);
}
};
simulateProgress();
const handler = this.downloadHandlers[type];
if (!handler) {
this.updateTaskStatus(
taskId,
'failed',
0,
undefined,
'No handler registered for this download type',
);
return of(undefined);
}
return handler(data, fileName).pipe(
tap(({ blob, fileName: actualFileName }) => {
const task = this.downloadQueue.find((t) => t.id === taskId);
if (task && actualFileName) {
task.fileName = actualFileName;
this.queueSubject.next([...this.downloadQueue]);
}
// custom method to download the byte array
downloadFromByteArray({ body: blob }, actualFileName || fileName);
this.updateTaskStatus(taskId, 'completed', 100, blob ?? undefined);
}),
catchError((error) => {
this.updateTaskStatus(
taskId,
'failed',
0,
undefined,
error?.message || 'Download failed',
);
return of(undefined);
}),
finalize(() => {
this.activeDownloads -= 1;
}),
map(() => undefined),
);
}

The pièce de résistance: This function is doing more juggling than a circus performer. It starts by updating the status to “downloading,” then simulates progress (because who doesn’t love a good progress bar?). The progress simulation is charmingly optimistic — it never quite reaches 100% until the actual download completes, because we’re not liars. When the download finishes, it calls our helper function to actually save the file, and always makes sure to free up a download slot when done.

The Helper Functions: The Unsung Heroes

private updateTaskStatus(
taskId: string,
status: DocumentDownloadTask['status'],
progress: number,
blob?: Blob,
error?: string,
): void {
const task = this.downloadQueue.find((t) => t.id === taskId);
if (task) {
task.status = status;
task.progress = progress;
if (blob) task.blob = blob;
if (error) task.error = error;
if (status === 'completed' || status === 'failed') {
task.completedAt = new Date();
}
this.queueSubject.next([...this.downloadQueue]);
}
}
private updateTaskProgress(taskId: string, progress: number): void {
const task = this.downloadQueue.find((t) => t.id === taskId);
if (task && task.status === 'downloading') {
task.progress = progress;
this.queueSubject.next([...this.downloadQueue]);
}
}
private extractFileName(contentDisposition: string): string {
return contentDisposition
.split(';')
.map((x) => x.trim())
.filter((y) => y.startsWith('filename='))
.map((z) => z.replace('filename=', '').replace(/"/g, ''))
.reduce((z) => z);
}
private generateTaskId(): string {
return `download_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}

The supporting cast: These functions are like the stage crew — you don’t see them, but without them, the show would be chaos. The updateTaskStatus function is our messenger, keeping everyone informed about what’s happening. The generateTaskId function creates unique IDs that are more unique than your taste in music.

The File Saver: downloadFromByteArray

export const downloadFromByteArray = (inputArray: any, fileName: string) => {
const file = new Blob([inputArray.body], {
type: getContentType(fileName),
});
saveAs(file, fileName);
};

Short but sweet: This little function takes your byte array and turns it into a file download. It’s like a magician pulling a rabbit out of a hat, except the rabbit is a spreadsheet and the hat is a byte array.

Using the Service: Where the Rubber Meets the Road

Now let’s see how to actually use this magnificent creation in your components:

ngOnInit(): void {
this.registerDownloadHandlers();
}
onExportExcelClick(): void {
const filterConfig = {
...this.scheduleListStoreService.filteringConfigSignal(),
};
const payload =
ScheduleListMapperService.mapToScheduleExcelPayloadDto(filterConfig);
this.documentQueueService.addDownloadTask(
DownloadType.ScheduleExcel,
DownloadTypeName.ScheduleExcel,
DownloadFileNames.ScheduleExcel,
{ payload },
);
}
private registerDownloadHandlers(): void {
this.documentQueueService.registerDownloadHandler(
DownloadType.ScheduleExcel,
this.createScheduleExcelExportHandler(),
);
}
private createScheduleExcelExportHandler(): (
data: any,
fileName: string,
) => Observable<{ blob: Blob; fileName: string }> {
return (data, fileName) =>
this.scheduleListService.exportSchedulesExcel(data.payload, true).pipe(
map((input) => ({
blob: new Blob([new Uint8Array(input)], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
fileName: fileName || DownloadFileNames.ScheduleExcel,
})),
);
}

Component-side simplicity: In your component, you register a handler that knows how to deal with Excel files (or whatever type you’re working with), then when the user clicks that export button, you just add the task and walk away. It’s like ordering takeout — you place the order and then go about your business while someone else handles the cooking.

The UI: Keeping Users in the Loop

Because transparency is key (and users love seeing progress bars), here’s how to show the download status in your navigation:

ngOnInit(): void {
this.documentQueueService.downloadTasks$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tasks) => {
this.downloadTasks = tasks;
this.downloadCount = tasks.length;
this.cdr.markForCheck();
});
}

And the corresponding HTML template:

@for (task of downloadTasks.slice().reverse(); track task) {
<div class="download-queue-item">
<span class="download-queue-title">
{{ task.name }}
<span class="download-queue-filename"
>({{ task.fileName }})</span
>
</span>
<span
class="download-queue-status"
[ngClass]="{
'download-status-completed': task.status === 'completed',
'download-status-failed': task.status === 'failed',
'download-status-downloading':
task.status === 'downloading',
}">
{{ task.status | titlecase }}
@if (task.status === 'downloading') {
<span>({{ task.progress }}%)</span>
}
</span>
</div>
}

The cherry on top: This gives your users a lovely little notification area where they can see all their downloads chugging along in the background. It’s like having a download manager built right into your app, showing progress and status updates without being intrusive.

Why This Approach Rocks

  1. Non-blocking: Users can continue using your app while downloads happen in the background
  2. Progress tracking: Real progress bars (well, simulated, but convincing ones)
  3. Concurrent downloads: Multiple files can download simultaneously (within reason)
  4. Type-safe: TypeScript keeps everything in line
  5. Extensible: Easy to add new download types by registering new handlers
  6. User-friendly: Clear status indicators and progress updates

Complete code queue service and helper with error handling

document.queue.service.ts

import { Injectable, OnDestroy } from '@angular/core';
import {
BehaviorSubject,
catchError,
concatMap,
finalize,
map,
mergeMap,
Observable,
of,
Subject,
} from 'rxjs';
import {
decodeDownloadFileName,
downloadFromByteArray,
} from '@customer-portal/shared/helpers/download';
export interface DocumentDownloadTask {
id: string;
name: string;
type: string;
fileName: string;
status: 'pending' | 'downloading' | 'completed' | 'failed';
progress: number;
data?: any;
blob?: Blob;
error?: string;
createdAt: Date;
completedAt?: Date;
}
type DownloadHandler = (
data: any,
fileName: string,
) => Observable<{ blob: Blob; fileName: string }>;
@Injectable({ providedIn: 'root' })
export class DocumentQueueService implements OnDestroy {
private downloadQueue: DocumentDownloadTask[] = [];
private queueSubject = new BehaviorSubject<DocumentDownloadTask[]>([]);
private downloadSubject = new Subject<{
taskId: string;
type: string;
fileName: string;
data: any;
}>();
private maxConcurrentDownloads = 5;
private activeDownloads = 0;
private downloadHandlers: Record<string, DownloadHandler> = {};
constructor() {
this.initializeDownloadProcessor();
}
get downloadTasks$(): Observable<DocumentDownloadTask[]> {
return this.queueSubject.asObservable();
}
ngOnDestroy(): void {
this.downloadSubject.complete();
this.queueSubject.complete();
}
addDownloadTask(
type: string,
name: string,
fileName: string,
data: any,
): string {
const taskId = this.generateTaskId();
const task: DocumentDownloadTask = {
id: taskId,
name,
type,
fileName,
status: 'pending',
progress: 0,
createdAt: new Date(),
data,
};
this.downloadQueue.push(task);
this.queueSubject.next([...this.downloadQueue]);
this.downloadSubject.next({ taskId, type, fileName, data });
return taskId;
}
registerDownloadHandler(type: string, handler: DownloadHandler): void {
this.downloadHandlers[type] = handler;
}
removeTask(taskId: string): void {
this.downloadQueue = this.downloadQueue.filter(
(task) => task.id !== taskId,
);
this.queueSubject.next([...this.downloadQueue]);
}
clearCompletedTasks(): void {
this.downloadQueue = this.downloadQueue.filter(
(task) => task.status !== 'completed' && task.status !== 'failed',
);
this.queueSubject.next([...this.downloadQueue]);
}
getTaskObservable(
taskId: string,
): Observable<DocumentDownloadTask | undefined> {
return this.queueSubject.pipe(
map((tasks) => tasks.find((task) => task.id === taskId)),
);
}
extractFileName(contentDisposition: string): string {
return contentDisposition
.split(';')
.map((x) => x.trim())
.filter((y) => y.startsWith('filename='))
.map((z) => z.replace('filename=', '').replace(/"/g, ''))
.reduce((z) => z);
}
private initializeDownloadProcessor(): void {
this.downloadSubject
.pipe(
concatMap(({ taskId, type, fileName, data }) =>
this.waitForAvailableSlot().pipe(
concatMap(() => this.processDownload(taskId, type, fileName, data)),
catchError((error) => {
this.updateTaskStatus(
taskId,
'failed',
0,
undefined,
error?.message || 'Download failed',
);
this.activeDownloads -= 1;
return of(undefined);
}),
),
),
)
.subscribe();
}
private waitForAvailableSlot(): Observable<void> {
return new Observable<void>((subscriber) => {
const checkSlot = () => {
if (this.activeDownloads < this.maxConcurrentDownloads) {
this.activeDownloads += 1;
subscriber.next();
subscriber.complete();
} else {
setTimeout(checkSlot, 100);
}
};
checkSlot();
});
}
private processDownload(
taskId: string,
type: string,
fileName: string,
data: any,
): Observable<void> {
this.updateTaskStatus(taskId, 'downloading', 0);
let progress = 0;
const simulateProgress = () => {
if (progress < 95) {
progress += Math.floor(Math.random() * 20) + 20;
if (progress > 95) progress = 95;
this.updateTaskProgress(taskId, progress);
setTimeout(simulateProgress, 200);
}
};
simulateProgress();
const handler = this.downloadHandlers[type];
if (!handler) {
this.updateTaskStatus(
taskId,
'failed',
0,
undefined,
'No handler registered for this download type',
);
return of(undefined);
}
return handler(data, fileName)
.pipe(
mergeMap(({ blob, fileName: actualFileName }) => {
const decodedFileName = decodeDownloadFileName(
actualFileName || fileName,
);
const task = this.downloadQueue.find((t) => t.id === taskId);
if (task && decodedFileName) {
task.fileName = decodedFileName;
this.queueSubject.next([...this.downloadQueue]);
}
return new Observable<void>((subscriber) => {
try {
downloadFromByteArray({ body: blob }, decodedFileName);
this.updateTaskStatus(
taskId,
'completed',
100,
blob ?? undefined,
);
} catch (err: any) {
this.updateTaskStatus(
taskId,
'failed',
0,
undefined,
err?.message || 'Download failed',
);
}
subscriber.next();
subscriber.complete();
});
}),
catchError((error) => {
this.updateTaskStatus(
taskId,
'failed',
0,
undefined,
error?.message || 'Download failed',
);
return of(undefined);
}),
)
.pipe(
finalize(() => {
this.activeDownloads -= 1;
}),
);
}
private updateTaskStatus(
taskId: string,
status: DocumentDownloadTask['status'],
progress: number,
blob?: Blob,
error?: string,
): void {
const task = this.downloadQueue.find((t) => t.id === taskId);
if (task) {
task.status = status;
task.progress = progress;
if (blob) task.blob = blob;
if (error) task.error = error;
if (status === 'completed' || status === 'failed') {
task.completedAt = new Date();
}
this.queueSubject.next([...this.downloadQueue]);
}
}
private updateTaskProgress(taskId: string, progress: number): void {
const task = this.downloadQueue.find((t) => t.id === taskId);
if (task && task.status === 'downloading') {
task.progress = progress;
this.queueSubject.next([...this.downloadQueue]);
}
}
private generateTaskId(): string {
return `download_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}

download.helpers.ts

enum HttpHeaders {
// Text files
TXT = 'text/plain',
HTML = 'text/html',
CSS = 'text/css',
CSV = 'text/csv',
// Image files
JPEG = 'image/jpeg',
JPG = 'image/jpg',
PNG = 'image/png',
GIF = 'image/gif',
BMP = 'image/bmp',
SVG = 'image/svg+xml',
WEBP = 'image/webp',
// Audio files
MP3 = 'audio/mpeg',
WAV = 'audio/wav',
OGG = 'audio/ogg',
// Video files
MP4 = 'video/mp4',
AVI = 'video/x-msvideo',
MKV = 'video/x-matroska',
MOV = 'video/quicktime',
WEBM = 'video/webm',
// Application files
JSON = 'application/json',
XML = 'application/xml',
PDF = 'application/pdf',
DOC = 'application/msword',
DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
XLS = 'application/vnd.ms-excel',
XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
PPT = 'application/vnd.ms-powerpoint',
PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
ZIP = 'application/zip',
GZIP = 'application/gzip',
TAR = 'application/x-tar',
RAR = 'application/vnd.rar',
RTF = 'application/rtf',
}
const extensionToContentType: Record<string, HttpHeaders> = {
TXT: HttpHeaders.TXT,
HTML: HttpHeaders.HTML,
CSS: HttpHeaders.CSS,
CSV: HttpHeaders.CSV,
JPEG: HttpHeaders.JPEG,
JPG: HttpHeaders.JPEG,
PNG: HttpHeaders.PNG,
GIF: HttpHeaders.GIF,
BMP: HttpHeaders.BMP,
SVG: HttpHeaders.SVG,
WEBP: HttpHeaders.WEBP,
MP3: HttpHeaders.MP3,
WAV: HttpHeaders.WAV,
OGG: HttpHeaders.OGG,
MP4: HttpHeaders.MP4,
AVI: HttpHeaders.AVI,
MKV: HttpHeaders.MKV,
MOV: HttpHeaders.MOV,
WEBM: HttpHeaders.WEBM,
JSON: HttpHeaders.JSON,
XML: HttpHeaders.XML,
PDF: HttpHeaders.PDF,
DOC: HttpHeaders.DOC,
DOCX: HttpHeaders.DOCX,
XLS: HttpHeaders.XLS,
XLSX: HttpHeaders.XLSX,
PPT: HttpHeaders.PPT,
PPTX: HttpHeaders.PPTX,
ZIP: HttpHeaders.ZIP,
GZIP: HttpHeaders.GZIP,
TAR: HttpHeaders.TAR,
RAR: HttpHeaders.RAR,
RTF: HttpHeaders.RTF,
};
export const getContentType = (
filename: string,
hasExtension = false,
): string => {
const extension = filename.split('.').pop();
const transformedExtension = extension ? extension.toUpperCase() : undefined;
if (
!transformedExtension ||
!(transformedExtension in extensionToContentType)
) {
throw new Error('Invalid or unsupported file extension');
}
if (hasExtension && extension) {
return extension;
}
return extensionToContentType[transformedExtension];
};
export const decodeDownloadFileName = (fileName: string): string => {
let decodedFileName = fileName;
if (decodedFileName.startsWith("UTF-8''")) {
decodedFileName = decodedFileName.substring(7);
}
try {
return decodeURIComponent(decodedFileName);
} catch {
return decodedFileName;
}
};
export const downloadFromByteArray = (inputArray: any, fileName: string) => {
let file: Blob;
try {
file = new Blob([inputArray.body], {
type: getContentType(fileName),
});
} catch (err) {
throw new Error(
`Invalid or unsupported file extension for file: ${fileName}`,
);
}
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
export const animateFlyToDownload = (sourceEl: HTMLElement) => {
let targetIcon = document.querySelector(
'.download-nav-icon.pi-spin',
) as HTMLElement;
let targetRect: DOMRect | null = null;
let iconFontSize = 15;
let iconColor = '#0f204b';
if (targetIcon) {
targetRect = targetIcon.getBoundingClientRect();
const computed = window.getComputedStyle(targetIcon);
iconFontSize = parseInt(computed.fontSize, 10) || 15;
iconColor = computed.color || '#0f204b';
} else {
targetIcon = document.querySelector('.download-nav-icon') as HTMLElement;
if (targetIcon) {
targetRect = targetIcon.getBoundingClientRect();
const computed = window.getComputedStyle(targetIcon);
iconFontSize = parseInt(computed.fontSize, 10) || 15;
iconColor = computed.color || '#0f204b';
} else {
const settingsBtn = document.querySelector(
'customer-portal-navbar-settings',
) as HTMLElement;
if (settingsBtn) {
const settingsRect = settingsBtn.getBoundingClientRect();
targetRect = {
left: settingsRect.left - 80,
top: settingsRect.top,
width: 24,
height: 24,
right: settingsRect.left - 36,
bottom: settingsRect.top + 24,
x: settingsRect.left - 60,
y: settingsRect.top,
toJSON: () => {},
} as DOMRect;
} else {
targetRect = {
left: window.innerWidth - 120,
top: 20,
width: 24,
height: 24,
right: window.innerWidth - 96,
bottom: 44,
x: window.innerWidth - 120,
y: 20,
toJSON: () => {},
} as DOMRect;
}
}
}
if (!targetRect) {
return;
}
const sourceRect = sourceEl.getBoundingClientRect();
const startX = sourceRect.left + sourceRect.width / 2 - iconFontSize / 2 + 20;
const startY = sourceRect.top + sourceRect.height / 2 - iconFontSize / 2;
const endX = targetRect.left + targetRect.width / 2 - iconFontSize / 2 + 38;
const endY = targetRect.top + targetRect.height / 2 - iconFontSize / 2;
const flyIcon = document.createElement('i');
flyIcon.className = 'pi pi-download fly-download-anim';
flyIcon.style.cssText = `
position: fixed;
left: ${startX}px;
top: ${startY}px;
font-size: ${iconFontSize}px;
color: ${iconColor};
z-index: 9999;
pointer-events: none;
opacity: 1;
box-shadow: 0 4px 16px 0 rgba(0,0,0,0.18);
border-radius: 50%;
background: #e0e0e0;
transform: scale(1) translate(0, 0);
transition: transform 0.9s cubic-bezier(0.4,0,0.2,1), opacity 0.9s cubic-bezier(0.4,0,0.2,1);
`;
document.body.appendChild(flyIcon);
setTimeout(() => {
const deltaX = endX - startX;
const deltaY = endY - startY;
flyIcon.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(1.2)`;
flyIcon.style.opacity = '1';
}, 50);
const cleanup = () => {
setTimeout(() => {
flyIcon.style.transition =
'transform 0.35s cubic-bezier(0.4,2,0.2,1), opacity 0.35s';
flyIcon.style.transform = `translate(${endX - startX}px, ${endY - startY}px) scale(0.2)`;
flyIcon.style.opacity = '0';
setTimeout(() => {
if (flyIcon.parentNode) {
flyIcon.parentNode.removeChild(flyIcon);
}
}, 350);
}, 50);
};
flyIcon.addEventListener('transitionend', cleanup, { once: true });
setTimeout(cleanup, 1200);
};

Conclusion: Download Nirvana Achieved

With this system, you’ve transformed from a developer whose users suffer through frozen UIs to one whose users barely notice when downloads are happening. Your app stays responsive, your users stay happy, and your download management is cleaner than a freshly debugged codebase.

The best part? Once you set this up, adding new download types is as simple as registering a new handler. It’s like having a Swiss Army knife for file downloads — versatile, reliable, and surprisingly elegant.

Now go forth and download responsibly

Learn more The Art of Non-Blocking Downloads in Angular: Because Nobody Likes a Frozen UI 🧊

Leave a Reply