Guide

If-Match, ETags, and Gmail API: Handling Concurrent Updates

If you build on the Gmail API directly, you will eventually run into conditional request headers, ETags, and 412 Precondition Failed responses. You end up wanting incremental sync and naturally build a polling solution, but the cleaner approach is Nylas webhooks or Pub/Sub. The Nylas CLI lets you test it with minimal effort.

What is If-Match?

If-Match is an HTTP request header for conditional requests. It tells the server: "only apply this write if the resource still matches this version." The version is represented by an ETag (entity tag) -- a string the server returns that uniquely identifies the current state of a resource.

The flow looks like this: you GET a resource and receive an ETag header in the response. When you later modify that resource, you send If-Match: "{etag}" along with your write. If someone else modified the resource in between, the ETag no longer matches and the server returns 412 Precondition Failed instead of silently overwriting their changes.

There is also If-None-Match, which does the inverse: it succeeds only when the resource does not match the provided ETag. This is mostly used for caching: send If-None-Match with a cached ETag and the server returns 304 Not Modified if nothing has changed, saving bandwidth.

How Gmail actually handles concurrency

Google REST APIs follow standard HTTP semantics and can return ETag headers on responses. But the Gmail API has its own concurrency mechanism that matters more in practice: historyId.

Every Gmail message, label change, and mailbox event is assigned a monotonically increasing historyId. When you list or get messages, the response includes the current historyId. You can then call history.list with a startHistoryId to get every change that happened since your last sync. This is how Gmail expects you to detect concurrent modifications rather than relying on ETag-based optimistic locking on individual messages.

That said, you will encounter 412 Precondition Failed in practice. The most common scenario is the Labels API: when you update or delete a label, Google enforces conditional semantics. If another client modified the label between your read and your write, you get a 412.

When you actually hit 412s

In practice, these are the situations where Gmail API returns 412 Precondition Failed:

  • Label updates and deletes - modifying a label's name, color, or visibility while another client does the same
  • Settings and filters - updating forwarding rules or filters that have been changed concurrently
  • Batch operations - when part of a batch touches a resource that was modified between batch construction and execution
  • Push notification watches - calling watch() when the existing watch was modified

For individual message operations like messages.modify (adding/removing labels on a message), Gmail is more lenient. These are typically last-write-wins. The 412 pattern applies more to shared configuration resources like labels and filters.

Example: Label update with If-Match

Here is a realistic example. You read a label, get its ETag, then update it:

# 1. GET a label, note the ETag in the response headers
curl -s -D- -H "Authorization: Bearer $TOKEN" \
  "https://gmail.googleapis.com/gmail/v1/users/me/labels/Label_42" \
  | grep -i etag
# => ETag: "abc123etag"

# 2. Update the label name, passing If-Match
curl -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H 'If-Match: "abc123etag"' \
  -H "Content-Type: application/json" \
  -d '{"name": "Clients/Active"}' \
  "https://gmail.googleapis.com/gmail/v1/users/me/labels/Label_42"

# If someone renamed this label in between, you get:
# HTTP/2 412 Precondition Failed

The retry loop you end up writing

Once you handle conditional requests, your code needs a retry loop. Every team that builds on Gmail directly ends up writing some version of this:

async function updateLabelWithRetry(
  labelId: string,
  update: Partial<Label>,
  maxRetries = 3
) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    // Read current state
    const res = await gmail.users.labels.get({
      userId: "me",
      id: labelId,
    });
    const etag = res.headers["etag"];

    try {
      // Write with conditional header
      return await gmail.users.labels.patch({
        userId: "me",
        id: labelId,
        requestBody: { ...res.data, ...update },
        headers: { "If-Match": etag },
      });
    } catch (err: any) {
      if (err.code === 412 && attempt < maxRetries - 1) {
        // Exponential backoff before retry
        await sleep(Math.pow(2, attempt) * 100);
        continue;
      }
      throw err;
    }
  }
}

This is boilerplate that every integration needs, for every resource type that enforces conditional writes. It gets more complex when you add batch operations, rate limiting, and partial failure handling on top.

historyId: Gmail's real sync mechanism

For message-level changes (new messages, label changes on messages, deletions), Gmail expects you to use historyId instead of ETags:

# 1. Do an initial list to get the current historyId
curl -H "Authorization: Bearer $TOKEN" \
  "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=1" \
  | jq '.resultSizeEstimate, .historyId'
# => "4521"

# 2. Later, fetch everything that changed since that point
curl -H "Authorization: Bearer $TOKEN" \
  "https://gmail.googleapis.com/gmail/v1/users/me/history?startHistoryId=4521" \
  | jq '.history[] | {messages: .messagesAdded, labels: .labelsAdded}'

This is how you build incremental sync without polling every message individually. But it means you need to persist the historyId, handle gaps (if you miss too many changes, you need a full re-sync), and merge history events into your local state. It is not trivial.

So you end up wanting incremental sync to solve these problems, and the natural path is to build a polling solution: periodically call history.list, persist the latest historyId, and merge changes. That works, but it is brittle and resource-heavy. The cleaner solution is to skip polling entirely and use Nylas webhooks or Pub/Sub notifications for push-based events.

Webhooks and Pub/Sub: push instead of polling

Instead of repeatedly asking "what changed?" you register to receive events when messages are created, updated, or deleted, and when labels or calendar events change. No historyId to persist, no gap handling, no merge logic. Nylas handles the complexity.

Pub/Sub is the more reliable and efficient option for production. It decouples the publisher from the subscriber, scales to high-volume notifications without overwhelming your endpoints, and supports message replay so you can retrieve missed notifications. Webhooks push directly to a URL and are simpler to set up, but they can drop events under load and require you to handle retries and scaling yourself.

The Nylas CLI lets you test your webhook or Pub/Sub solution with minimal effort. Run nylas webhook server to start a local server that receives webhook payloads. Use a tunnel (e.g. ngrok or cloudflared) to expose it, then create a webhook pointing at that URL:

# Terminal 1: start local webhook server
nylas webhook server

# Terminal 2: create webhook pointing at your tunnel URL
nylas webhook create \
  --url https://your-ngrok-id.ngrok.io/webhook \
  --triggers message.created,message.updated,message.deleted

Use nylas webhook test send URL to send a test payload to any URL without waiting for real events, so you can validate your handler logic in seconds. For Pub/Sub, configure the notification channel via the Nylas API or dashboard.

When to use what

ApproachConcurrency / syncBest for
Gmail API directlyYou manage ETags, historyId, 412 retries, rate limitsDeep Gmail-only integrations, custom sync engines
Nylas webhooksPush events to a URL; simpler setupGetting started, low-volume apps
Nylas Pub/SubPush events via queue; message replay, scales under loadProduction, high-volume, enterprise

Next steps