Tutorials 8 min read

How to Migrate from Polling to Webhooks: A Step-by-Step Guide

Still polling APIs every few seconds? Learn how to migrate to webhooks for real-time updates, lower costs, and better performance—without downtime.

H

HookWatch Team

February 1, 2026

Polling worked when you started. Check the API every 30 seconds, see if anything changed. But now you're making thousands of empty requests per hour, hitting rate limits, and your updates are always 30 seconds behind. It's time to switch to webhooks.

Why Migrate?

The Cost of Polling

Let's do the math. Polling an API every 30 seconds for 100 resources:

  • Requests per hour: 100 resources × 120 polls/hour = 12,000 requests
  • Requests per day: 288,000 requests
  • Requests per month: 8.64 million requests
  • Useful requests: Maybe 1% contain actual changes

That's 8.5 million wasted API calls per month.

Webhooks by Comparison

With webhooks, you only receive HTTP requests when something actually changes:

  • Requests per month: Only as many as there are actual events
  • Latency: Near-instant vs. up to 30 seconds
  • API quota usage: Near zero vs. millions of requests

The Migration Plan

Phase 1: Set Up Webhook Receivers

Build your webhook endpoint alongside your existing polling code:

Javascript
// New webhook receiver
app.post('/webhooks/orders', async (req, res) => {
  const event = req.body;

  // Verify authenticity
  if (!verifySignature(req)) {
    return res.status(401).send('Invalid signature');
  }

  // Process the event
  await processOrderUpdate(event);

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

// Same processing logic used by both polling and webhooks
async function processOrderUpdate(data) {
  const existingOrder = await db.orders.findOne({
    externalId: data.id
  });

  if (existingOrder && existingOrder.updatedAt >= data.updated_at) {
    return; // Already up to date
  }

  await db.orders.upsert({
    externalId: data.id,
    status: data.status,
    data: data,
    updatedAt: data.updated_at,
    source: data._source || 'unknown' // Track where updates come from
  });
}

Phase 2: Run Both in Parallel

Keep polling running while you verify webhooks are working:

Javascript
// Modified polling with tracking
async function pollForUpdates() {
  const updates = await api.getRecentOrders({
    since: lastPollTime
  });

  for (const order of updates) {
    // Tag the source so we can compare
    order._source = 'polling';

    const existing = await db.orders.findOne({
      externalId: order.id
    });

    if (existing && existing.source === 'webhook') {
      // Webhook already handled this - log for verification
      metrics.increment('webhook_beat_polling');

      if (existing.updatedAt >= order.updated_at) {
        continue; // Webhook was faster, skip
      }
    }

    await processOrderUpdate(order);
    metrics.increment('polling_processed');
  }
}

Phase 3: Monitor and Compare

Track metrics to build confidence:

Javascript
// Dashboard metrics to watch
const migrationMetrics = {
  // Events received by source
  webhookEvents: 0,
  pollingEvents: 0,

  // Webhook speed advantage
  webhookFirst: 0,  // Webhook arrived before polling found it
  pollingFirst: 0,  // Polling found it before webhook arrived

  // Reliability
  webhookOnly: 0,   // Only caught by webhook
  pollingOnly: 0,   // Only caught by polling (webhook missed)
  both: 0           // Caught by both
};

// Log daily summary
function logMigrationReport() {
  console.log({
    type: 'migration_report',
    date: new Date().toISOString(),
    ...migrationMetrics,
    webhookCoverage: (
      (migrationMetrics.webhookOnly + migrationMetrics.both) /
      (migrationMetrics.webhookOnly + migrationMetrics.pollingOnly +
       migrationMetrics.both)
    * 100).toFixed(1) + '%'
  });
}

Phase 4: Reduce Polling Frequency

Once webhooks prove reliable, slow down polling to act as a safety net:

Javascript
// Before migration: poll every 30 seconds
const POLL_INTERVAL = 30 * 1000;

// During migration: poll every 5 minutes
const POLL_INTERVAL = 5 * 60 * 1000;

// After confidence: poll every hour (reconciliation only)
const POLL_INTERVAL = 60 * 60 * 1000;

Phase 5: Remove Polling

When webhook coverage is consistently above 99.9%, remove polling entirely:

Javascript
// Replace active polling with periodic reconciliation
async function reconcile() {
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);

  // Only check for gaps, not all resources
  const apiOrders = await api.getOrders({
    since: oneHourAgo
  });

  const dbOrders = await db.orders.find({
    updatedAt: { $gte: oneHourAgo }
  });

  const dbOrderIds = new Set(dbOrders.map(o => o.externalId));
  const missed = apiOrders.filter(o => !dbOrderIds.has(o.id));

  if (missed.length > 0) {
    console.warn(`Reconciliation found ${missed.length} missed events`);
    for (const order of missed) {
      await processOrderUpdate(order);
    }
  }
}

// Run reconciliation every hour
setInterval(reconcile, 60 * 60 * 1000);

Handling Edge Cases

What If the Provider Doesn't Support Webhooks?

Some APIs don't offer webhooks. In that case, build your own event system:

Javascript
// Smart polling that publishes webhook-like events
async function smartPoll() {
  const current = await api.getResources();
  const previous = await cache.get('last_poll_result');

  const changes = detectChanges(previous, current);

  for (const change of changes) {
    // Publish as internal webhook
    await eventBus.publish({
      type: `resource.${change.action}`,
      data: change.resource,
      detectedAt: new Date()
    });
  }

  await cache.set('last_poll_result', current);
}

function detectChanges(previous, current) {
  const changes = [];
  const prevMap = new Map(previous?.map(r => [r.id, r]) || []);

  for (const resource of current) {
    const prev = prevMap.get(resource.id);
    if (!prev) {
      changes.push({ action: 'created', resource });
    } else if (prev.updated_at !== resource.updated_at) {
      changes.push({ action: 'updated', resource });
    }
    prevMap.delete(resource.id);
  }

  for (const [id, resource] of prevMap) {
    changes.push({ action: 'deleted', resource });
  }

  return changes;
}

Handling the Transition Period

During migration, some events may be processed by both systems. Ensure idempotency:

Javascript
async function processOrderUpdate(data) {
  // Use external ID + updated_at as idempotency key
  const key = `${data.id}:${data.updated_at}`;

  const alreadyProcessed = await cache.get(`processed:${key}`);
  if (alreadyProcessed) return;

  await db.orders.upsert({
    externalId: data.id,
    status: data.status,
    data: data,
    updatedAt: data.updated_at
  });

  await cache.set(`processed:${key}`, true, 86400); // 24h TTL
}

Migration Checklist

  1. Build webhook endpoint with signature verification
  2. Share processing logic between polling and webhook handlers
  3. Run both systems in parallel with source tracking
  4. Monitor webhook coverage for at least 1 week
  5. Gradually reduce polling frequency
  6. Implement hourly reconciliation as a safety net
  7. Remove polling code after 30 days of stable webhook delivery

Using HookWatch During Migration

HookWatch makes the migration safer:

  • Delivery logs show exactly which webhooks arrived and when
  • Retry handling ensures you don't miss events during the transition
  • Replay lets you re-process events if you find bugs in your new handler
  • Alerts notify you immediately if webhook delivery drops

Migrate with confidence knowing HookWatch has your back.

Tags: migrationpollingwebhookstutorialperformance

Share this article

Ready to try HookWatch?

Start monitoring your webhooks in minutes. No credit card required.

Start Free Today