← Back to Home

How to Implement Progress Callbacks for Long-Running Operations

Updated January 14, 2026
progresscallbacksasyncui updateslong-running

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;
    }
  };
}