Webhooks vs polling
Published
You are waiting for a courier. Polling is calling the courier company every ten minutes to ask "has it left yet?". A webhook is giving them your number and asking them to call you when it leaves. The second is obviously better, until the phone is switched off when they call, or they call twice, or someone else rings pretending to be the courier.
Between services, a webhook is an HTTP POST that a provider sends to a URL you registered,
when an event happens on their side: Razorpay telling you a payment succeeded, GitHub telling
you someone pushed, Shopify telling you an order was placed.
Your app receives Razorpay's 'payment.captured' webhook and marks the order paid. List everything that could go wrong between Razorpay sending it and your database being correct.
What polling costs#
Polling is simple and in your control, and it is wasteful in exact proportion to how rarely things change.
Shorter intervals cut the delay and multiply the waste, and the provider will rate limit you long before you get to real time. Webhooks send exactly one request per event, immediately.
Webhooks, and the five rules for receiving them#
A webhook is efficient and immediate. It is also an incoming request from the internet that affects your money, arriving at least once, possibly out of order, and possibly not at all if your endpoint was down long enough. Receiving them correctly means five habits.
- Verify the signature on the raw request body with the shared secret, using a constant
time compare. Check the timestamp included in the signature (Stripe's
Stripe-Signatureheader has one) and reject old ones, so a captured request cannot be replayed later. - Acknowledge fast, process later. Return 2xx as soon as the event is safely stored or queued. Slow handlers cause timeouts, and timeouts cause retries.
- Deduplicate by event id. Providers retry on any failure or timeout, so the same event can arrive twice. See delivery guarantees.
- Do not trust order. "payment.refunded" can arrive before "payment.captured". Either compare timestamps or versions, or fetch the object's current state from the API when an event arrives.
- Reconcile. If your endpoint was down for longer than the provider retries (Stripe retries for up to three days, others less), events are lost. A periodic job that lists recent objects from the API and fixes any mismatch is the safety net. Webhooks for speed, polling for correctness.
import { createHmac, timingSafeEqual } from 'node:crypto'
// Use the raw body bytes, exactly as received. Parsing and re-serialising
// JSON changes whitespace and breaks the signature.
export function verify(rawBody: Buffer, header: string, secret: string) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(header)
return a.length === b.length && timingSafeEqual(a, b)
}Sending webhooks#
If you are the provider, the mirror image applies: sign every payload with a per customer secret and include a timestamp; send from a queue with retries and exponential backoff over hours or days; give each event a unique id; let customers see delivery attempts and replay events from a dashboard; disable endpoints that fail for days and email the owner. And do not let one customer's slow endpoint hold up everyone else's deliveries: isolate per customer queues or concurrency.
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Polling | Simple, works through firewalls, the client controls the pace, nothing to secure on your side. | Wasted requests, delay of up to one interval, provider rate limits. | Few objects to watch, slow changing data, environments that cannot receive inbound calls, and as reconciliation. |
| Webhooks | Immediate, one request per event, no wasted calls. | Public endpoint to secure, at least once and unordered delivery, lost events if down too long. | Payments, Git events, order updates: anything event driven between companies. |
| Both: thin webhook + fetch + reconcile | Speed of webhooks, correctness of reading current state, safety net for lost events. | More code paths. | Anything where money or orders depend on it. |
- StripeSigned webhooks with timestamps, 3 days of retries
- RazorpayX-Razorpay-Signature, HMAC SHA-256
- GitHubX-Hub-Signature-256, redelivery from the UI
- SvSvix / HookdeckWebhook sending and receiving as a service
In the interview#
Checkpoint#
1. Why should a webhook endpoint return 200 quickly and process the event later?
2. Why verify the signature on the raw body rather than parsed JSON?
3. Your endpoint was down for four days. What protects you from lost events?
- Polling: you ask on a timer. Webhook: they call you on each event.
- Verify HMAC on the raw body + timestamp.
- Ack fast, process async, dedupe by event id.
- Events are unordered: fetch current state.
- Reconcile with polling. Webhooks for speed, polling for correctness.
Polling asks a provider for changes on a timer: simple, under the client's control and firewall friendly, but most requests find nothing and updates arrive late by up to an interval. A webhook is an HTTP POST the provider sends to your URL when an event happens: immediate and efficient, but it is a public endpoint receiving at least once, unordered, and possibly lost deliveries. So I verify the HMAC signature on the raw body with a timestamp to block forgery and replay, return 200 as soon as the event is queued, deduplicate by event id in the same transaction as the effect, fetch the object's current state instead of trusting event order, and run a reconciliation job that polls the provider's API to catch anything missed. When sending webhooks, I sign them, retry with backoff from a queue, isolate slow customers, and let customers replay events.