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
6 min read
Sponsored
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.

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
| Factor | Favors webhooks | Favors polling |
|---|---|---|
| Latency requirement | Need near-real-time updates | A delay of seconds to minutes is fine |
| Endpoint availability | You can expose and maintain a public HTTPS endpoint | You’re behind a firewall, in local dev, or don’t want the exposure |
| Event frequency | Events are frequent; polling would waste most requests | Events are rare; polling rarely finds “nothing changed” |
| Provider support | Provider offers reliable webhook delivery with signing | Provider has no webhooks, or its webhook reliability is questionable |
| Operational maturity | You can build and maintain signature verification, dedup, and retry handling | You 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
More from this category
More from Web Development
R.01 MySQL Now Has a Native VECTOR Type. Should You Drop pgvector For It?
R.02 React 19.2 vs 'React 20': There Is No React 20, and Here's What's Actually New
R.03 Next.js's First Scheduled Security Release Shipped: 9 Advisories, What to Patch
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored