DEV Community

Dean
Dean

Posted on

How I built Google Drive sync without a backend (and the 3 bugs that almost broke me)

When I started building PenPage — a privacy-first note app that
stores everything in IndexedDB — I made one assumption that cost me
three weeks of debugging:

"Google Drive sync will be the easy part."

It wasn't.

Here's what I learned building a sync engine entirely in the
browser, with no backend server.


## The core idea: one file to rule them all

Instead of syncing every note file individually, I built around
a single sync.json that stores all metadata:

  ppage-app/
  ├── sync.json        ← the source of truth
  ├── pages/
  │   └── page-*.md   ← actual note content
  └── images/
Enter fullscreen mode Exit fullscreen mode

sync.json holds folder structure, page metadata, image metadata,
and device info — but NOT page content. On every sync:

  1. Download sync.json (or skip if modifiedTime hasn't changed)
  2. Compare local IndexedDB state vs Drive state
  3. Upload/download only what changed
  4. Upload the new sync.json

This keeps API calls to 2-3 per sync cycle instead of N×2 per file.


## Bug #1: The 404 that wasn't really a 404

Google Drive returns 404 when you try to access a folder that's
been deleted and recreated — even if a folder with the same name
exists now.

This hit me when implementing "Force Upload" (which recreates the
app folder from scratch). Device A would force upload, delete and
recreate the folder. Device B still had the old folder ID cached
— and every API call returned 404.

The fix: wrap every Drive operation in a recovery handler:

  private async withFolderRecovery<T>(
    operation: () => Promise<T>
  ): Promise<T> {
    try {
      return await operation()
    } catch (error) {
      if (error.message.includes('404')) {
        await this.reinitialize() // re-fetch all folder IDs
        return await operation()  // retry once
      }
      throw error
    }
  }
Enter fullscreen mode Exit fullscreen mode

Any 404 triggers a full folder ID refresh, then retries. Simple,
but it took me a while to realize the root cause.


## Bug #2: The silent data corruption hiding in a sentinel value

Every sync, I run a cleanup step that repairs folder parentId
values. The check looked like this:

  // Intended: fix folders with wrong parentId
  if (isRootParentId(folder.parentId)) {
    repairFolder(folder)
  }
Enter fullscreen mode Exit fullscreen mode

isRootParentId() returned true for both 'workspace' (the
actual sentinel for "orphaned folder") AND 'root' (the correct
value for top-level user folders).

Result: every sync, ALL top-level folders got their updatedAt
timestamp refreshed to Date.now(). The comparison logic saw
local as newer than Drive → uploaded everything → silently
overwrote changes from other devices.

The fix:

  // Only match the actual bad sentinel value
  if (folder.parentId === 'workspace') {
    repairFolder(folder)
  }
Enter fullscreen mode Exit fullscreen mode

One character difference. Weeks of mysterious "my changes
disappeared" reports.


## Bug #3: IndexedDB index queries don't match undefined

Force Download is supposed to: clear local data → import from Drive.

But after Force Download, pages appeared blank. The root cause was
a chain of four silent failures:

  1. clearAllData() queries IndexedDB by workspaceId: 'global'
  2. Old records had workspaceId: undefined (pre-migration data)
  3. IndexedDB index queries are exact matchundefined'global'
  4. Old records survived the clear
  5. importAll() tried to create records with same IDs → store.add() silently fails on duplicate keys
  6. New records never written → UI shows nothing
  // ❌ Misses orphan records where workspaceId is undefined
  const pages = await db.getAllPages({ workspaceId: 'global' })

  // ✅ Explicit orphan cleanup
  const allPages = await db.getAllPages()
  const orphans = allPages.filter(p => !p.workspaceId)
  await deleteAll(orphans)
Enter fullscreen mode Exit fullscreen mode

Lesson: store.add() failure on duplicate keys is silent.
store.put() overwrites. Know which one you're using.


## What actually works well

Despite the bugs, the architecture held up:

  • modifiedTime as a proxy for "changed" — no polling, no webhooks, no server
  • Parallel uploads (5 concurrent) reduced sync time 80% for large note sets
  • Tombstones in sync.json for deleted pages — other devices learn about deletions without needing the deleted file

## Would I do it again?

Yes, but I'd budget 3× more time for edge cases. The Google Drive
API docs describe the happy path. The bugs live in:

  • Stale cached folder IDs
  • Silent IndexedDB failures
  • Sentinel value collisions in your own data model

If you're building anything with Google Drive API or browser-side
sync, happy to answer questions in the comments.

PenPage: https://penpage.com


Top comments (0)