Implementing Progress Callbacks for Long-Running Operations
Progress callbacks keep users informed during long-running operations like file uploads or batch processing.
Progress Interface
interface OperationProgress {
current: number;
total: number;
status: 'processing' | 'completed' | 'failed';
message?: string;
}
type ProgressCallback = (progress: OperationProgress) => void;
Basic Implementation
async function processItems<T>(
items: T[],
onProgress?: ProgressCallback
): Promise<T[]> {
const results: T[] = [];
for (let i = 0; i < items.length; i++) {
// Report progress
onProgress?.({
current: i + 1,
total: items.length,
status: 'processing',
message: `Processing item ${i + 1} of ${items.length}`,
});
// Process item
const result = await processItem(items[i]);
results.push(result);
}
// Report completion
onProgress?.({
current: items.length,
total: items.length,
status: 'completed',
message: 'All items processed',
});
return results;
}
With Error Handling
async function processItemsWithErrorHandling<T>(
items: T[],
onProgress?: ProgressCallback
): Promise<{ success: T[]; failed: Array<{ item: T; error: string }> }> {
const success: T[] = [];
const failed: Array<{ item: T; error: string }> = [];
for (let i = 0; i < items.length; i++) {
onProgress?.({
current: i + 1,
total: items.length,
status: 'processing',
message: `Processing item ${i + 1}`,
});
try {
const result = await processItem(items[i]);
success.push(result);
} catch (error) {
failed.push({
item: items[i],
error: error instanceof Error ? error.message : 'Unknown error',
});
onProgress?.({
current: i + 1,
total: items.length,
status: 'processing',
message: `Failed item ${i + 1}: ${error}`,
});
}
}
const allFailed = failed.length === items.length;
onProgress?.({
current: items.length,
total: items.length,
status: allFailed ? 'failed' : 'completed',
message: allFailed ? 'All items failed' : `${success.length} succeeded, ${failed.length} failed`,
});
return { success, failed };
}
Service Layer Pattern
class AssetGenerationService {
async generateAssets(
article: FullArticle,
onProgress?: (progress: {
sceneNumber: number;
totalScenes: number;
operation: 'image' | 'audio';
status: 'generating' | 'completed' | 'failed';
}) => void
): Promise<void> {
const scenes = article.script?.scenes || [];
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i];
// Generate image
onProgress?.({
sceneNumber: scene.sceneNumber,
totalScenes: scenes.length,
operation: 'image',
status: 'generating',
});
await this.generateImage(scene);
onProgress?.({
sceneNumber: scene.sceneNumber,
totalScenes: scenes.length,
operation: 'image',
status: 'completed',
});
// Generate audio
onProgress?.({
sceneNumber: scene.sceneNumber,
totalScenes: scenes.length,
operation: 'audio',
status: 'generating',
});
await this.generateAudio(scene);
onProgress?.({
sceneNumber: scene.sceneNumber,
totalScenes: scenes.length,
operation: 'audio',
status: 'completed',
});
}
}
}
UI Integration
import ora from 'ora';
import chalk from 'chalk';
async function withProgressSpinner<T>(
operation: (onProgress: ProgressCallback) => Promise<T>,
operationName: string
): Promise<T> {
const spinner = ora();
return new Promise((resolve, reject) => {
operation((progress) => {
const percentage = Math.round((progress.current / progress.total) * 100);
if (progress.status === 'processing') {
spinner.text = chalk.cyan(
`${operationName}: ${progress.current}/${progress.total} (${percentage}%)`
);
spinner.render();
} else if (progress.status === 'completed') {
spinner.succeed(chalk.green(`${operationName} completed`));
resolve();
} else if (progress.status === 'failed') {
spinner.fail(chalk.red(`${operationName} failed: ${progress.message}`));
reject(new Error(progress.message));
}
});
});
}
Batch Processing with Sub-Progress
interface BatchProgress {
batchIndex: number;
totalBatches: number;
batchProgress: OperationProgress;
}
async function processBatches(
batches: T[][],
onProgress?: (progress: BatchProgress) => void
): Promise<void> {
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
await processItems(batch, (itemProgress) => {
onProgress?.({
batchIndex: i,
totalBatches: batches.length,
batchProgress: itemProgress,
});
});
}
}
Percentage Calculation
function calculatePercentage(current: number, total: number): number {
return Math.round((current / total) * 100);
}
function formatProgress(current: number, total: number): string {
const percentage = calculatePercentage(current, total);
return `[${current}/${total}] ${percentage}%`;
}
// Examples:
// calculatePercentage(5, 10) // 50
// formatProgress(5, 10) // "[5/10] 50%"
Estimated Time Remaining
class ProgressTracker {
private startTime: number;
constructor(private total: number) {
this.startTime = Date.now();
}
getEstimate(current: number): {
elapsed: string;
remaining: string;
percentage: number;
} {
const now = Date.now();
const elapsedMs = now - this.startTime;
const avgTimePerItem = elapsedMs / current;
const remainingMs = avgTimePerItem * (this.total - current);
return {
elapsed: this.formatDuration(elapsedMs),
remaining: this.formatDuration(remainingMs),
percentage: Math.round((current / this.total) * 100),
};
}
private formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
}
// Usage
const tracker = new ProgressTracker(100);
const estimate = tracker.getEstimate(25);
// { elapsed: "0m 30s", remaining: "1m 30s", percentage: 25 }
Throttled Progress Updates
function createThrottledProgressCallback(
callback: ProgressCallback,
throttleMs: number = 100
): ProgressCallback {
let lastCall = 0;
let pendingProgress: OperationProgress | null = null;
return (progress: OperationProgress) => {
pendingProgress = progress;
const now = Date.now();
if (now - lastCall >= throttleMs) {
lastCall = now;
callback(pendingProgress);
pendingProgress = null;
}
};
}