logo

How to Handle Long-Running API Calls

Some API calls take a long time. AI model inference, PDF report generation, payment processing, fetching data from slow third-party services — these can range from seconds to minutes. Making these calls synchronously forces users to stare at a loading spinner while your server ties up a connection.

New to async processing? Check the glossary for key terms: task queue, webhook, callback, payload.

The Problem

// Your endpoint blocks until the slow API responds
app.post('/api/generate-report', async (req, res) => {
  const data = await fetchAnalyticsData(req.body.dateRange);   // 3-5 seconds
  const report = await callReportingAPI(data);                  // 10-60 seconds
  const pdf = await generatePDF(report);                        // 5-15 seconds

  res.json({ reportUrl: pdf.url }); // User waited 18-80 seconds
});

This causes several problems:

  • Timeouts: Load balancers, API gateways, and browsers all have timeout limits (often 30 seconds)
  • Resource exhaustion: Each pending request holds a connection and memory
  • Poor UX: Users have no visibility into progress and may retry, compounding the load

The Solution

Step 1: Identify the long-running API call

Measure your endpoints. Any call that regularly exceeds a few seconds is a candidate for background processing. Common examples:

  • AI/ML inference: Image generation, LLM completions, video analysis
  • Report generation: Aggregating data and rendering PDFs or spreadsheets
  • Third-party APIs: Payment processing, shipping rate calculations, credit checks
  • Data pipelines: Extract, Transform, Load (ETL) jobs, bulk imports, cross-service data synchronization

Step 2: Offload the call to AsyncQueue

Replace the inline API call with a task and respond to the user immediately with a job ID:

app.post('/api/generate-report', async (req, res) => {
  const jobId = crypto.randomUUID();

  await saveToDatabase({
    id: jobId,
    type: 'report',
    status: 'pending',
    params: req.body,
  });

  await aq.tasks.create({
    callbackUrl: 'https://your-app.com/api/run-report',
    payload: {
      jobId,
      dateRange: req.body.dateRange,
      format: req.body.format || 'pdf',
    },
    webhookUrl: 'https://your-app.com/api/on-report-ready',
    retries: 3,
    timeout: 120, // seconds — generous limit for slow APIs
  });

  res.json({ jobId, status: 'pending' });
});

Your endpoint now responds in milliseconds, giving the user a job ID to track progress.

Step 3: Build a callback endpoint to execute the call

This endpoint does the heavy lifting. AsyncQueue calls it and waits for the result:

app.post('/api/run-report', async (req, res) => {
  const { jobId, dateRange, format } = req.body;

  await updateDatabase(jobId, { status: 'processing' });

  const data = await fetchAnalyticsData(dateRange);
  const report = await callReportingAPI(data);
  const pdf = await generatePDF(report);

  res.json({
    jobId,
    reportUrl: pdf.url,
    pageCount: pdf.pages,
  });
});

If the call fails, AsyncQueue automatically retries based on your configuration.

Step 4: Handle the result via webhook

When the task completes, AsyncQueue delivers the result to your webhook:

app.post('/api/on-report-ready', async (req, res) => {
  const { jobId, reportUrl, pageCount } = req.body.result;

  await updateDatabase(jobId, {
    status: 'completed',
    reportUrl,
    pageCount,
  });

  await notifyUser(jobId, 'Your report is ready to download.');

  res.status(200).json({ received: true });
});

Step 5: Add timeout handling and retries

Configure your tasks for the reality of unreliable external APIs:

await aq.tasks.create({
  callbackUrl: 'https://your-app.com/api/run-report',
  payload: { jobId, dateRange },
  webhookUrl: 'https://your-app.com/api/on-report-ready',
  retries: 3,        // retry up to 3 times on failure
  timeout: 120,      // allow up to 2 minutes per attempt
});

On the frontend, let users check status with polling:

const pollJobStatus = async (jobId) => {
  const res = await fetch(`/api/jobs/${jobId}`);
  const data = await res.json();

  if (data.status === 'completed') {
    showDownloadLink(data.reportUrl);
  } else if (data.status === 'failed') {
    showError('Report generation failed. Please try again.');
  } else {
    setTimeout(() => pollJobStatus(jobId), 3000);
  }
};

When to Use This Pattern

ScenarioTypical DurationBackground?
AI image generation5–60sYes
PDF report generation10–120sYes
Payment processing2–10sDepends
Sending transactional email1–3sYes
Third-party data enrichment5–30sYes
Database query<1sNo

As a rule of thumb: if it can timeout, it should be a background job.