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/
sync.json holds folder structure, page metadata, image metadata,
and device info — but NOT page content. On every sync:
- Download
sync.json(or skip ifmodifiedTimehasn't changed) - Compare local IndexedDB state vs Drive state
- Upload/download only what changed
- 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
}
}
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)
}
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)
}
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:
-
clearAllData()queries IndexedDB byworkspaceId: 'global' - Old records had
workspaceId: undefined(pre-migration data) - IndexedDB index queries are exact match —
undefined≠'global' - Old records survived the clear
-
importAll()tried to create records with same IDs →store.add()silently fails on duplicate keys - 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)
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:
-
modifiedTimeas 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)