Skip to content

Web Development · Architecture Patterns

Webhooks vs Polling: How to Choose the Right Integration Pattern

Webhooks push data to you the moment something happens; polling has you ask repeatedly whether anything changed. Here's how to decide which fits your integration, plus real code for both and the hybrid pattern most production systems actually need.

Prathviraj Singh

Prathviraj Singh

6 min read

Webhooks vs Polling: How to Choose the Right Integration Pattern

Sponsored

Share

A client asks you to sync order status from their payment provider into their dashboard. The obvious question, webhook or polling, has an answer most teams get to by trial and error instead of by reasoning it through up front. Here’s the actual decision, plus the pattern almost every production system ends up using regardless of which one they pick first: both.

The core difference

A webhook is push-based. The provider’s system detects an event, an order paid, a subscription cancelled, a deployment finished, and sends an HTTP POST to a URL you registered, carrying the event data. You don’t ask; you get told.

Polling is pull-based. You send a request to the provider’s API on some schedule, asking “has anything changed?” or “what’s the current status of X?”, and the provider answers based on whatever state exists at that moment. You ask; you might get told nothing changed.

Webhook push flow versus polling pull flow

That difference in direction drives almost every other tradeoff between them.

Webhooks: implementation and what it actually costs you

A webhook handler looks simple on the surface, receive a POST, do something with it, but the parts that matter are the ones tutorials skip.

import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.PROVIDER_WEBHOOK_SECRET;

app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-provider-signature'];
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  // Constant-time comparison to avoid timing attacks on the signature check
  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);

  // Deduplicate: providers retry on timeout, so the same event ID
  // can arrive more than once. Check before processing.
  if (await alreadyProcessed(event.id)) {
    return res.status(200).send('Already processed');
  }

  await handleEvent(event);
  await markProcessed(event.id);

  // Respond fast. Do slow work (emails, downstream syncs) asynchronously,
  // after acknowledging receipt, not before.
  res.status(200).send('OK');
});

Three things in that handler aren’t optional: signature verification (so an attacker who finds your endpoint URL can’t send fabricated events), deduplication (because providers retry deliveries on timeout, and “at least once” delivery is the norm, not “exactly once”), and responding quickly (a slow handler risks the provider treating the delivery as failed and retrying, compounding the duplicate problem). Skip any of the three and you have a webhook handler that works in the demo and misbehaves in production.

The other cost is infrastructure: you need a publicly reachable HTTPS endpoint, which means it has to exist before the first event can arrive, has to stay up, and has to be reachable from wherever the provider’s servers are, which rules it out for local development without a tunneling tool and for services sitting behind a restrictive firewall.

Polling: implementation and what it actually costs you

Polling avoids all of that infrastructure, at the cost of efficiency and latency.

import time
import requests

def poll_for_completion(job_id, timeout_seconds=300):
    start = time.monotonic()
    delay = 1  # start at 1 second, back off from there

    while time.monotonic() - start < timeout_seconds:
        response = requests.get(f"https://api.provider.com/jobs/{job_id}")
        status = response.json()["status"]

        if status in ("completed", "failed"):
            return status

        time.sleep(delay)
        delay = min(delay * 2, 30)  # exponential backoff, capped at 30s

    raise TimeoutError(f"Job {job_id} did not complete within {timeout_seconds}s")

Exponential backoff matters here for the same reason it matters in retry logic generally: a fixed short interval wastes requests once you’re clearly in for a longer wait, and a fixed long interval adds unnecessary latency for jobs that finish quickly. Starting short and backing off gives you both fast detection for the common case and reasonable request volume for the slow one.

The cost of polling is structural, not a bug to fix: every poll that finds no change is a wasted request, and your maximum latency for noticing a change is bounded by your polling interval, not by how fast the underlying event actually happened. Poll every 60 seconds, and a change that happens right after a poll won’t be visible to you for up to 60 seconds.

Deciding which one to use

FactorFavors webhooksFavors polling
Latency requirementNeed near-real-time updatesA delay of seconds to minutes is fine
Endpoint availabilityYou can expose and maintain a public HTTPS endpointYou’re behind a firewall, in local dev, or don’t want the exposure
Event frequencyEvents are frequent; polling would waste most requestsEvents are rare; polling rarely finds “nothing changed”
Provider supportProvider offers reliable webhook delivery with signingProvider has no webhooks, or its webhook reliability is questionable
Operational maturityYou can build and maintain signature verification, dedup, and retry handlingYou want the simplest thing that works today

If the provider supports webhooks and your infrastructure can host an endpoint, webhooks are usually the better default for anything where near-real-time matters. If you’re integrating with something that updates state infrequently, or you genuinely can’t expose an endpoint, polling with a sensible interval is the pragmatic choice, not a compromise.

The pattern most production systems land on: both

Webhook delivery is not guaranteed. Your endpoint can be down during a deploy, a network issue can drop a delivery in transit, or a silent bug in your handler can fail to process an event without anyone noticing until a customer complains their order status never updated. Providers typically retry failed deliveries for a while, but “for a while” is not “forever,” and gaps happen.

The fix most mature integrations converge on is running webhooks for low-latency updates, plus a lower-frequency reconciliation poll as a safety net:

# Runs every hour, independent of webhook delivery, to catch anything missed
def reconcile_order_statuses():
    local_pending = get_orders_with_status("pending", older_than_minutes=30)
    for order in local_pending:
        remote_status = fetch_order_status(order.provider_id)
        if remote_status != order.status:
            update_order_status(order.id, remote_status)
            log.warning(f"Reconciliation caught drift on order {order.id}")

If that reconciliation job never finds drift, it’s cheap insurance. If it does find drift, it’s telling you your webhook handler has a gap worth investigating, which is a far better way to find out than a customer support ticket.

This is the same durability instinct behind idempotent request handling: assume delivery isn’t perfect, and build the system to correct itself rather than trusting a single mechanism completely. If you’re designing webhook handlers that also need to be safe against duplicate or out-of-order delivery, our guide to idempotency keys for safe API retries covers the same underlying problem from the request side rather than the event side, and the two patterns compose well together.

Pick webhooks for the default path when latency matters and the provider supports them well. Add polling back in as reconciliation, not as an afterthought, once you’ve shipped the webhook integration and want to trust it in production rather than just in the demo.

Frequently asked questions

Why not just always use webhooks if they're more efficient?
Webhooks require your system to expose a publicly reachable HTTPS endpoint, which isn't always possible (internal tools behind a corporate firewall, local development, some enterprise network policies) or desirable from a security posture. They also shift responsibility onto you: you have to verify the request is genuinely from the provider, handle duplicate deliveries, and deal with the fact that if your endpoint is down when an event fires, you may need the provider's retry mechanism or a separate reconciliation process to catch what you missed.
Why would I ever choose polling over webhooks if the provider offers both?
Simplicity and control. Polling doesn't require exposing an endpoint, doesn't require signature verification, and puts you in charge of exactly when you check for updates, which matters if you need predictable load rather than being at the mercy of when the provider decides to send events. It's also the only option when the provider doesn't support webhooks at all, which is still common with older or simpler APIs.
What does 'reconciliation polling' mean if I'm already using webhooks?
It means running a periodic, lower-frequency poll against the provider's API as a safety net, independent of your webhook handler. Webhook delivery isn't guaranteed: your endpoint might be down during a deploy, a network blip might drop a delivery, or a bug in your handler might silently fail to process an event. A reconciliation job that runs every hour or so and cross-checks state catches whatever the webhook path missed, without needing every state change to flow through it at the webhook's low latency.
How do I verify a webhook is actually from the provider and not an attacker?
Check the signature. Almost every reputable webhook provider signs its payloads with a shared secret (Stripe, GitHub, and most others use an HMAC signature in a request header) and you must verify that signature against the raw request body before trusting the payload. Skipping this step means anyone who discovers or guesses your webhook URL can send you fabricated events, which is a real attack surface, not a theoretical one.
What polling interval should I use if I go that route?
Start with the loosest interval your use case tolerates, then tighten it only if you have evidence it's needed. A dashboard showing order status might poll every 30-60 seconds; a background sync job might poll every 15 minutes. Use exponential backoff when polling for a specific expected change (like waiting for an async job to finish) so you're not hammering the API at a fixed rate while waiting for something that might take anywhere from 2 seconds to 2 minutes.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored