brandonwie.dev
EN / KR
On this page
backend backendgoogle-calendarsyncerror-handling

Sync Token Invalidation Recovery (410 GONE)

When Google Calendar API returns 410 GONE, the sync token is invalidated and a

Updated March 22, 2026 4 min read

I was investigating a production bug where users were losing their custom notes and space assignments on calendar events. After a sync cycle, all user-specific data was gone — replaced with a clean copy from Google. The trigger was a 410 GONE response from the Google Calendar API, which invalidated our sync token and kicked off a full resync. The resync code deleted everything and recreated from scratch, wiping out all application-specific data in the process.

Why Sync Tokens Invalidate

Google’s Calendar API uses sync tokens for incremental synchronization — you pass the token from your last sync, and Google returns only the changes since then. But tokens can be invalidated, returning 410 GONE instead of changes. From Google’s documentation:

“Sync tokens are invalidated by the server for various reasons including token expiration or changes in related ACLs.”

The key insight: 410 GONE isn’t limited to time-based expiration. ACL changes (permission changes on a calendar) also invalidate tokens. A user getting shared access to a calendar, or losing it, can trigger a full resync on a completely unrelated integration.

The Data Loss Bug

The original resync handler was brutally simple — delete everything and recreate from Google:

// ❌ DANGEROUS: Loses Moba-specific data
async handleResync(calendarId: string) {
  await this.blockRepo.delete({ calendarId });  // Gone!
  const events = await this.googleApi.listEvents(calendarId);
  await this.createBlocksFromEvents(events);
}

This approach destroyed all application-specific data that doesn’t exist in Google: custom notes (note field), link data (linkData), space assignments (spaceId), and any user customizations. Google is the source of truth for calendar event data, but the application owns its own data — and a resync should never throw that away.

The Fix: Strategy Selection by Access Role

The solution is to use different recovery strategies based on the calendar’s access level. Editable calendars (owner/writer) can have user customizations that need preserving. Read-only calendars (reader/freeBusyReader) can’t have user customizations, so a clean-slate resync is safe:

async handleResync(calendar: Calendar) {
  const accessRole = calendar.accessRole;

  if (isEditableCalendar(accessRole)) {
    // MERGE: Preserve Moba-specific fields
    await this.mergeResync(calendar);
  } else {
    // CLEAN-SLATE: Safe for read-only calendars
    await this.cleanSlateResync(calendar);
  }
}

function isEditableCalendar(accessRole: string | null): boolean {
  if (!accessRole) {
    Sentry.captureMessage('accessRole is null during 410 recovery');
    return false;  // Treat as non-editable (clean-slate)
  }
  return ['owner', 'writer'].includes(accessRole);
}

Merge Strategy (Editable Calendars)

For calendars where the user can create and modify events, the merge strategy preserves application-specific fields while updating Google-sourced fields:

async mergeResync(calendar: Calendar) {
  const events = await this.googleApi.listEvents(calendar.gcalId);

  for (const event of events) {
    const existing = await this.blockRepo.findOne({
      where: { calendarId: calendar.id, gcalId: event.id }
    });

    if (existing) {
      // UPDATE: Keep Moba fields, update Google fields
      await this.updateBlockFromEvent(existing, event);
    } else {
      // INSERT: New event from Google
      await this.createBlockFromEvent(event);
    }
  }
}

Clean-Slate Strategy (Read-Only Calendars)

For read-only calendars, there’s no application-specific data to protect. A clean delete-and-recreate is safe and simpler:

async cleanSlateResync(calendar: Calendar) {
  // Safe: Read-only calendars have no Moba-specific data
  await this.blockRepo.delete({ calendarId: calendar.id });
  const events = await this.googleApi.listEvents(calendar.gcalId);
  await this.createBlocksFromEvents(events);
}

ACL-Aware Recovery

Since 410 GONE can be triggered by ACL changes, the calendar’s access role might have changed since the last sync. Fetch fresh metadata before selecting a recovery strategy:

async handleFindEventsWithResync(calendar: Calendar) {
  const result = await this.findEvents(calendar);

  if (result.resyncRequired) {
    // CRITICAL: Refresh metadata before retry
    const freshCalendar = await this.googleApi.getCalendar(calendar.gcalId);

    if (freshCalendar) {
      // Update ALL fields from Google
      await this.updateCalendar(calendar.id, freshCalendar);
    }

    // Retry with updated accessRole
    return this.handleResync(calendar);
  }

  return result;
}

Without this refresh step, you might run a merge strategy on a calendar that’s now read-only (wasted effort) or a clean-slate strategy on a calendar that’s now editable (data loss).

Decision Matrix

accessRoleStrategyReason
ownerMergeUser can customize
writerMergeUser can customize
readerClean-slateRead-only, no customizations
freeBusyReaderClean-slateOnly sees free/busy
nullClean-slateUnexpected, log to Sentry

Takeaway

When handling 410 GONE from Google Calendar’s sync API, never do a blind delete-and-recreate. Split your recovery into two strategies based on accessRole: merge for editable calendars (preserves application-specific data), clean-slate for read-only calendars (safe and fast). Always refresh calendar metadata before choosing a strategy, because the 410 itself might have been triggered by the ACL change that altered the access role.

References

Comments

enko