Skip to main content

Command Palette

Search for a command to run...

Asynchronous API Design: Webhooks Polling SSE

Published
8 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Asynchronous API Design: Webhooks vs Polling vs Server-Sent Events

The Long-Running Operation Problem

You've built an API endpoint that processes video transcoding, generates AI-powered reports, or runs complex data analytics. The operation takes 30 seconds—or maybe 5 minutes. Your client sits there, connection timing out, retry logic failing, and your server resources burning while holding that HTTP connection open.

This is the asynchronous API problem that's plagued developers since REST became mainstream. In 2026, with microservices architectures and serverless functions dominating the landscape, synchronous request-response patterns simply don't cut it anymore. Modern applications demand real-time updates, efficient resource utilization, and resilient communication patterns.

Let's explore three battle-tested approaches to asynchronous API design: webhooks, polling, and Server-Sent Events (SSE). We'll examine why traditional solutions fail, implement modern TypeScript solutions, and navigate the pitfalls that can derail your implementation.

Why Traditional Synchronous APIs Fail

The classic HTTP request-response model assumes operations complete within seconds. When you POST data to an endpoint, you expect an immediate response. But reality is messier:

Timeout Cascades: Most HTTP clients timeout after 30-60 seconds. Load balancers and proxies often enforce even stricter limits. When your operation exceeds these thresholds, connections drop, leaving clients uncertain about operation status.

Resource Exhaustion: Keeping connections open consumes server resources—memory, file descriptors, and thread pools. Scale this to thousands of concurrent long-running operations, and your infrastructure crumbles.

Poor User Experience: Users stare at loading spinners with no progress indication. Did the operation fail? Is it still processing? The synchronous model provides no answers.

Retry Complexity: When timeouts occur, clients must implement retry logic. But how do you retry idempotently? How do you avoid duplicate processing? These questions complicate client implementations significantly.

Approach 1: Polling

Polling is the simplest asynchronous pattern. The server immediately returns a job identifier, and clients periodically check the job's status.

Modern TypeScript Implementation

// Server-side (Express + TypeScript)
import express, { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';

interface Job {
  id: string;
  status: 'pending' | 'processing' | 'completed' | 'failed';
  result?: any;
  error?: string;
  createdAt: Date;
  updatedAt: Date;
}

const jobs = new Map<string, Job>();

app.post('/api/process', async (req: Request, res: Response) => {
  const jobId = uuidv4();

  jobs.set(jobId, {
    id: jobId,
    status: 'pending',
    createdAt: new Date(),
    updatedAt: new Date()
  });

  // Start async processing
  processAsync(jobId, req.body).catch(err => {
    const job = jobs.get(jobId);
    if (job) {
      job.status = 'failed';
      job.error = err.message;
      job.updatedAt = new Date();
    }
  });

  res.status(202).json({ jobId, statusUrl: `/api/jobs/${jobId}` });
});

app.get('/api/jobs/:jobId', (req: Request, res: Response) => {
  const job = jobs.get(req.params.jobId);

  if (!job) {
    return res.status(404).json({ error: 'Job not found' });
  }

  res.json(job);
});

async function processAsync(jobId: string, data: any): Promise<void> {
  const job = jobs.get(jobId);
  if (!job) return;

  job.status = 'processing';
  job.updatedAt = new Date();

  // Simulate long-running operation
  const result = await performHeavyOperation(data);

  job.status = 'completed';
  job.result = result;
  job.updatedAt = new Date();
}
// Client-side polling with exponential backoff
class PollingClient {
  private baseDelay = 1000;
  private maxDelay = 30000;
  private maxAttempts = 60;

  async submitAndWait(data: any): Promise<any> {
    const response = await fetch('/api/process', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });

    const { jobId, statusUrl } = await response.json();
    return this.pollUntilComplete(statusUrl);
  }

  private async pollUntilComplete(statusUrl: string): Promise<any> {
    let attempts = 0;
    let delay = this.baseDelay;

    while (attempts < this.maxAttempts) {
      await this.sleep(delay);

      const response = await fetch(statusUrl);
      const job = await response.json();

      if (job.status === 'completed') {
        return job.result;
      }

      if (job.status === 'failed') {
        throw new Error(job.error);
      }

      attempts++;
      delay = Math.min(delay * 1.5, this.maxDelay);
    }

    throw new Error('Polling timeout exceeded');
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

Pitfalls: Polling generates unnecessary traffic. Aggressive polling wastes bandwidth and server resources. Conservative polling delays result delivery. Finding the right balance is challenging, and exponential backoff helps but doesn't eliminate the fundamental inefficiency.

Approach 2: Webhooks

Webhooks invert the communication model. Instead of clients asking "are you done yet?", servers proactively notify clients when operations complete.

Modern TypeScript Implementation

// Server-side webhook implementation
interface WebhookJob extends Job {
  webhookUrl?: string;
  webhookSecret?: string;
}

app.post('/api/process-webhook', async (req: Request, res: Response) => {
  const { webhookUrl, webhookSecret, ...data } = req.body;
  const jobId = uuidv4();

  const job: WebhookJob = {
    id: jobId,
    status: 'pending',
    webhookUrl,
    webhookSecret,
    createdAt: new Date(),
    updatedAt: new Date()
  };

  jobs.set(jobId, job);

  processWithWebhook(jobId, data).catch(console.error);

  res.status(202).json({ jobId });
});

async function processWithWebhook(jobId: string, data: any): Promise<void> {
  const job = jobs.get(jobId) as WebhookJob;
  if (!job) return;

  try {
    job.status = 'processing';
    const result = await performHeavyOperation(data);

    job.status = 'completed';
    job.result = result;
    job.updatedAt = new Date();

    await sendWebhook(job);
  } catch (error) {
    job.status = 'failed';
    job.error = (error as Error).message;
    await sendWebhook(job);
  }
}

async function sendWebhook(job: WebhookJob, attempt = 1): Promise<void> {
  if (!job.webhookUrl) return;

  const maxAttempts = 5;
  const payload = {
    jobId: job.id,
    status: job.status,
    result: job.result,
    error: job.error,
    timestamp: new Date().toISOString()
  };

  const signature = createHmacSignature(
    JSON.stringify(payload),
    job.webhookSecret || ''
  );

  try {
    const response = await fetch(job.webhookUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': signature
      },
      body: JSON.stringify(payload)
    });

    if (!response.ok && attempt < maxAttempts) {
      await new Promise(resolve => 
        setTimeout(resolve, Math.pow(2, attempt) * 1000)
      );
      return sendWebhook(job, attempt + 1);
    }
  } catch (error) {
    if (attempt < maxAttempts) {
      await new Promise(resolve => 
        setTimeout(resolve, Math.pow(2, attempt) * 1000)
      );
      return sendWebhook(job, attempt + 1);
    }
  }
}

function createHmacSignature(payload: string, secret: string): string {
  const crypto = require('crypto');
  return crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
}

Pitfalls: Webhooks require clients to expose publicly accessible endpoints—challenging in development environments and behind corporate firewalls. Webhook delivery isn't guaranteed; networks fail, endpoints go down, and retry logic becomes complex. Security is paramount; always validate webhook signatures to prevent spoofing attacks.

Approach 3: Server-Sent Events (SSE)

SSE provides a persistent, unidirectional connection from server to client, perfect for real-time progress updates.

Modern TypeScript Implementation

// Server-side SSE implementation
app.get('/api/process-sse', async (req: Request, res: Response) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const jobId = uuidv4();

  const sendEvent = (event: string, data: any) => {
    res.write(`event: ${event}\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };

  sendEvent('started', { jobId });

  try {
    const data = req.query.data as string;
    const result = await performHeavyOperationWithProgress(
      JSON.parse(data),
      (progress) => sendEvent('progress', { progress })
    );

    sendEvent('completed', { jobId, result });
  } catch (error) {
    sendEvent('error', { jobId, error: (error as Error).message });
  } finally {
    res.end();
  }
});

// Client-side SSE consumer
class SSEClient {
  async processWithUpdates(
    data: any,
    onProgress: (progress: number) => void
  ): Promise<any> {
    return new Promise((resolve, reject) => {
      const eventSource = new EventSource(
        `/api/process-sse?data=${encodeURIComponent(JSON.stringify(data))}`
      );

      eventSource.addEventListener('progress', (e) => {
        const { progress } = JSON.parse(e.data);
        onProgress(progress);
      });

      eventSource.addEventListener('completed', (e) => {
        const { result } = JSON.parse(e.data);
        eventSource.close();
        resolve(result);
      });

      eventSource.addEventListener('error', (e) => {
        const { error } = JSON.parse(e.data);
        eventSource.close();
        reject(new Error(error));
      });

      eventSource.onerror = () => {
        eventSource.close();
        reject(new Error('SSE connection failed'));
      };
    });
  }
}

Pitfalls: SSE connections consume server resources for their entire duration. Unlike webhooks, you can't easily scale horizontally without sticky sessions or Redis pub/sub. Browser support is excellent, but SSE is unidirectional—if you need bidirectional communication, consider WebSockets instead.

Best Practices

Choose Based on Requirements: Use polling for simple scenarios with infrequent updates. Choose webhooks for server-to-server communication where clients can expose endpoints. Opt for SSE when you need real-time progress updates in browser applications.

Implement Idempotency: All operations should be idempotent. Use unique job IDs and track processing state to prevent duplicate execution.

Set Reasonable Timeouts: Jobs shouldn't run indefinitely. Implement timeouts and cleanup mechanisms for abandoned jobs.

Secure Your Endpoints: Validate webhook signatures, use HTTPS, implement rate limiting, and authenticate all requests.

Monitor and Alert: Track job completion rates, webhook delivery success, and connection durations. Alert on anomalies.

Frequently Asked Questions

Q: Can I combine these approaches? A: Absolutely. Offer webhooks as the primary mechanism with polling as a fallback. This provides flexibility for different client capabilities.

Q: How long should I retain job status? A: Implement a TTL (time-to-live) based on your requirements. 24-48 hours is common, but adjust based on your use case and storage constraints.

Q: What about WebSockets? A: WebSockets provide bidirectional communication and are excellent for interactive applications. However, they're more complex than SSE and often overkill for simple async operations.

Q: How do I handle webhook failures? A: Implement exponential backoff with a maximum retry count. Store failed webhooks in a dead-letter queue for manual investigation.

Q: Should I use a message queue? A: For production systems, yes. Redis, RabbitMQ, or AWS SQS provide durability, scalability, and better failure handling than in-memory job storage.

Q: How do I test webhooks locally? A: Use tools like ngrok to expose local endpoints, or implement a webhook relay service for development environments.

Q: What's the best approach for mobile apps? A: Polling with exponential backoff works well for mobile. SSE can drain batteries, and webhooks require complex infrastructure like push notifications.

Conclusion

Asynchronous API design isn't one-size-fits-all. Polling offers simplicity at the cost of efficiency. Webhooks provide real-time notifications but require infrastructure complexity. SSE delivers real-time updates with simpler client implementations but consumes server resources.

In 2026, the most robust systems combine approaches: webhooks for reliable server-to-server communication, SSE for real-time browser updates, and polling as a universal fallback. Choose based on your specific requirements, infrastructure capabilities, and client constraints.

The key is understanding the tradeoffs and implementing proper error handling, security, and monitoring regardless of your chosen approach. Your users will thank you for the responsive, reliable experience.


Metadata

```json { "seo_title": "Asynchronous API Design: Webhooks vs Polling vs SSE Guide", "meta_description": "Master async API patterns for 2026. Compare webhooks, polling, and Server-Sent Events with TypeScript examples, best practices, and real-world pitfalls to avoid.", "primary_keyword": "asynchronous API design", "secondary_keywords": [ "webhooks vs polling", "Server-Sent Events", "async API patterns", "TypeScript API implementation", "long-running operations", "real-time API updates", "webhook implementation", "API polling strategies" ], "tags": [ "API Design", "TypeScript", "Webhooks", "Server-Sent Events", "Async Programming", "Backend Development", "REST API" ] }

Asynchronous API Design: Webhooks Polling SSE