# Webhooks vs polling for syncing data

Source: https://developer.nylas.com/docs/cookbook/use-cases/build/webhooks-vs-polling/

You're deciding how to keep your app's copy of a user's mailbox and calendar in sync. Two patterns exist: webhooks push events to you the moment they happen, and polling asks the API for changes on a schedule. They aren't equal, and the wrong default can blow your rate-limit budget or leave users staring at stale data.

The two patterns compare on four axes: latency, rate-limit cost, reliability, and complexity. Below you'll find that comparison and the hybrid pattern most production apps land on. For the step-by-step setup, see [Get real-time updates with webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/) and [Get a webhook for new email](/docs/cookbook/use-cases/build/new-email-webhook/).

## What's the difference between webhooks and polling?

Webhooks push: you register one HTTPS endpoint with `POST /v3/webhooks/`, and the API sends a JSON notification the instant a `message.created` or `event.updated` trigger fires. Polling pulls: your code calls `GET /v3/grants/{grant_id}/messages` on a timer and diffs the results. Webhooks react in seconds; a 5-minute poll averages 2.5 minutes of lag.

The cost gap is just as wide. A webhook stays silent when nothing changes, so an idle mailbox sends zero traffic. A poller runs on its schedule regardless, burning requests against the 200-per-grant-per-second [Messages rate limit](/docs/dev-guide/platform/rate-limits/) even when the inbox is quiet. At 10,000 grants polled every 5 minutes, that's 2 million requests an hour for changes that mostly didn't happen.

## What does polling every 5 minutes actually cost?

A 5-minute poll spends one request per grant per cycle whether or not the mailbox changed, and it adds an average of 2.5 minutes of lag because a message that arrives one second after a cycle waits for the next one. That fixed cost scales linearly with your grant count, so the bill grows even when nothing happens.

Run the numbers. One poll per grant every 5 minutes is 12 cycles an hour, or 288 requests per grant per day. At 1,000 grants that's 288,000 requests a day; at 10,000 grants it's 2.88 million. The vast majority return an empty diff, because a typical mailbox receives well under 288 new messages a day. Webhooks invert that ratio: an account that gets 50 messages a day fires roughly 50 `message.created` notifications, each arriving within seconds, and stays silent for the remaining hours. The same 10,000 accounts that cost 2.88 million poll requests generate around 500,000 webhook deliveries, every one of them carrying a real change.

Latency is the other half. The 2.5-minute average poll lag is the best case on a 5-minute interval; tighten the interval to cut lag and your request count climbs in lockstep. A 1-minute poll quarters the lag to 30 seconds but burns 1,440 requests per grant per day, five times the cost. Webhooks hold lag near the delivery time, usually a few seconds, with no interval to tune.

## When should I use webhooks vs polling?

Use webhooks for live, user-facing sync where freshness matters: new mail, calendar moves, sent-folder updates. Use polling for backfill, periodic reconciliation, or batch jobs where a few minutes of lag is fine. Most apps need both, because webhooks deliver sub-10-second freshness and polling delivers a guaranteed catch-up path when an event is missed.

The table compares the two across the 5 factors that decide the call. The recommended default for live sync is the **Webhooks** column.

| Factor               | Polling                                                  | **Webhooks**                                                     |
| -------------------- | ------------------------------------------------------- | --------------------------------------------------------------- |
| Latency              | 2.5 min average on a 5-min poll                         | **Seconds after the event fires**                               |
| Rate-limit cost      | Constant load, even on idle grants                      | **Zero traffic when nothing changes**                           |
| Reliability          | Deterministic; a missed cycle self-heals on the next    | **At-least-once delivery; retries twice on non-200**            |
| Complexity           | Scheduled loop plus cursor bookkeeping per grant        | **One public HTTPS endpoint and a signature check**             |
| Setup effort         | Write the loop; no inbound endpoint needed              | **Register a destination, verify it, handle the payload**       |
| Infrastructure       | A scheduler or timer; no public ingress                 | **A public HTTPS endpoint that answers within 10 seconds**      |
| Missed-event recovery| Caught on the next poll by design                       | **Needs a catch-up poll if the endpoint was down**              |

Webhooks win on 4 of the 5 factors. The one row where polling is structurally safer is missed events: Nylas retries a failed delivery, but if your endpoint is down for 10 minutes, those notifications can still slip past. That single gap is why the hybrid pattern below exists.

## How does the hybrid pattern combine webhooks and polling?

The hybrid pattern runs webhooks for live updates and a low-frequency poll for reconciliation. Webhooks handle 99% of changes in seconds. A slow poll, say every 30 minutes, queries each grant for anything newer than your last synced timestamp and catches events dropped while your endpoint was down. You get push speed with pull safety.

The reconciliation poll is cheap because it runs rarely and fetches a small window. Querying once per grant every 30 minutes is 48 calls per grant per day, well under the 200-per-second [Messages rate limit](/docs/dev-guide/platform/rate-limits/). The Node.js handler below acknowledges a webhook fast, then upserts the affected message so your store stays current.

### How often should the reconciliation poll run?

Match the cadence to how much staleness you can tolerate between the moment a webhook is dropped and the moment your catch-up poll recovers it. A 30-to-60-minute interval suits most user-facing apps; a nightly run fits reporting or analytics stores where end-of-day accuracy is enough. Pick the longest interval your freshness budget allows, because each shorter interval multiplies the request count.

The catch-up poll exists to close one gap: notifications Nylas couldn't deliver. Two mechanisms fail independently. Each notification gets three delivery attempts over 10 to 20 minutes, then Nylas skips it. Separately, [endpoint health](/docs/v3/notifications/#failing-and-failed-webhook-endpoints) moves to `failing` after 95% non-`200` responses over 15 minutes, where Nylas keeps attempting deliveries for 72 hours before marking it `failed` and stopping. A reconciliation poll shorter than that 72-hour window re-fetches whatever either mechanism dropped on its next pass. Always bound the poll with `received_after` set to the grant's last successful sync timestamp so each run fetches only the new tail, not the whole mailbox. A 60-minute reconciliation poll is 24 calls per grant per day, or 240,000 across 10,000 grants, roughly a twelfth of what a 5-minute primary poll would spend.

## What causes a webhook to be missed, and how does the poll recover it?

A webhook goes undelivered when your endpoint can't return a `200 OK` in time: the server is down, the handler is slow past the 10-second timeout, a deploy drops in-flight requests, or a network blip cuts the connection. Nylas retries twice more, backing off exponentially, with the final attempt landing 10 to 20 minutes after the first.

After three failed attempts Nylas skips that notification and moves on to the next, so a sustained outage can leave a hole in your data even though delivery resumes afterward. The reconciliation poll closes that hole. Because it queries each grant with `received_after` against your last synced timestamp, it returns every message that arrived during the outage regardless of whether the matching webhook ever reached you. The live path keeps you current second to second; the poll guarantees nothing is lost over a longer horizon.

```js [hybridSync-Node.js]


const app = express();
const nylas = new Nylas({ apiKey: process.env.NYLAS_API_KEY });

// Live path: webhook fires within seconds of a new message.
app.post("/webhooks/nylas", express.json(), async (req, res) => {
  // Acknowledge inside 10 seconds or Nylas retries the delivery.
  res.status(200).send();

  const { type, data } = req.body;
  if (type === "message.created" || type === "message.updated") {
    const { id, grant_id } = data.object;
    const message = await nylas.messages.find({
      identifier: grant_id,
      messageId: id,
    });
    await upsertMessage(message.data); // your DB write
  }
});

app.listen(3000);
```

The reconciliation path is a separate scheduled job, not part of the request handler. It pages through messages received since the grant's last sync point and upserts each one, so any event missed during downtime is recovered on the next run.

```js [hybridReconcile-Node.js]
// Runs on a 30-minute cron, independent of the webhook endpoint.
async function reconcile(grantId, lastSyncedUnix) {
  let pageToken;
  do {
    const page = await nylas.messages.list({
      identifier: grantId,
      queryParams: {
        limit: 200, // 200 is the max page size for Messages
        received_after: lastSyncedUnix,
        ...(pageToken && { pageToken }),
      },
    });
    for (const message of page.data) await upsertMessage(message);
    pageToken = page.nextCursor; // page_token cursor for the next page
  } while (pageToken);
}
```

## How do I write the hybrid pattern in Python?

The Python version mirrors the Node.js handler: the webhook endpoint returns `200` immediately, then fetches and upserts the changed message. Calendar triggers (`event.created`, `event.updated`, `event.deleted`) flow through the same endpoint, so one handler covers both email and calendar sync across all 6 providers.

The Flask route below acknowledges the delivery first, because Nylas retries any notification that doesn't get a `200` within 10 seconds. Reading `message.created` and `message.updated` keeps your store aligned with the mailbox as mail arrives and changes.

```python
from flask import Flask, request
from nylas import Client

app = Flask(__name__)
nylas = Client(api_key=os.environ["NYLAS_API_KEY"])


@app.post("/webhooks/nylas")
def handle_webhook():
    payload = request.get_json()
    event_type = payload["type"]

    if event_type in ("message.created", "message.updated"):
        obj = payload["data"]["object"]
        message = nylas.messages.find(
            identifier=obj["grant_id"],
            message_id=obj["id"],
        )
        upsert_message(message.data)  # your DB write

    # Acknowledge inside 10 seconds or Nylas retries delivery.
    return "", 200
```

The reconciliation job runs on its own 30-minute schedule. It pages through messages newer than the grant's last sync timestamp and upserts each one, recovering anything the live path missed during an outage.

```python
def reconcile(grant_id, last_synced_unix):
    page_token = None
    while True:
        response = nylas.messages.list(
            identifier=grant_id,
            query_params={
                "limit": 200,  # 200 is the max page size for Messages
                "received_after": last_synced_unix,
                **({"page_token": page_token} if page_token else {}),
            },
        )
        for message in response.data:
            upsert_message(message)
        page_token = response.next_cursor  # cursor for the next page
        if not page_token:
            break
```

## When is polling the right call on its own?

Polling alone is the better fit when a few minutes of lag costs you nothing and a public endpoint costs you something. Reach for it in these four cases: scheduled batch or ETL jobs that run on a timer anyway, environments with no public HTTPS endpoint to receive a push, low-frequency data that changes a handful of times a day, and local development or automated testing where standing up a tunnel to receive webhooks is more friction than it's worth. A nightly job touches each grant once in 24 hours, so the steady cost stays trivial.

In those cases the steady request cost is negligible because you control the interval and the data volume is low. A nightly ETL job that pages a grant's messages once with `received_after` set to the previous run spends a few calls per grant per day, far under the 200-per-second [Messages rate limit](/docs/dev-guide/platform/rate-limits/). You also skip the inbound endpoint, the signature check, and the 10-second acknowledgement deadline entirely. If your workload is a timed job rather than a live feed, polling is the simpler and cheaper design.

## Should I choose webhooks, polling, or both?

Map your workload to one of these four scenarios. A user-facing inbox or calendar view where freshness is visible points to **webhooks plus a reconciliation poll**, the hybrid that delivers updates within seconds while a 60-minute catch-up poll backstops them. A nightly analytics or reporting sync points to **polling alone**, bounded by `received_after`, because end-of-day accuracy is the only requirement.

A local prototype or CI test points to **polling alone** as well, since exposing a public endpoint adds setup with no payoff at that stage. A high-volume production integration syncing thousands of grants points back to **the hybrid**: webhooks keep the per-grant cost near zero on idle accounts, and a poll every 60 minutes bounds your exposure to the 72-hour `failed`-endpoint window. The short version is that any live, user-visible sync wants webhooks with a catch-up poll, and any timed or offline job wants polling on its own.

## What's next

- [Get real-time updates with webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/) to set up the webhook destination and triggers
- [Get a webhook for new email](/docs/cookbook/use-cases/build/new-email-webhook/) for an end-to-end `message.created` flow
- [Retry and debug failed webhooks](/docs/cookbook/use-cases/build/retry-failed-webhooks/) to harden the live path against missed deliveries
- [Backfill historical email](/docs/cookbook/email/backfill-historical-email/) for the initial-sync side of polling
- [API and provider rate limits](/docs/dev-guide/platform/rate-limits/) for the per-grant budgets that polling spends