Two-way sync between Google Sheets and MySQL, keeping both sides identical. Edit either side and both converge, automatically, within about a second.
Sheet edits push over a webhook, database edits are picked up by a change detector, and every sync runs through a Redis-backed queue that batches bursts, retries failures and keeps exactly one sync in flight at a time.
Google Sheet MySQL
| |
| onEdit (Apps Script) change poller |
v v
POST /api/webhook/sheet ----> [ BullMQ queue ] <----------
|
sync worker
|
read both sides, plan the diff,
apply it, write both sides back
|
WebSocket /ws
|
React dashboard
- Automatic, both directions. An installable Apps Script
onEdittrigger posts to the server the moment a cell changes. The database side is polled with a cheap fingerprint query. Neither side needs a human to press anything. - Bursts collapse into one sync. Pasting 500 rows fires many triggers; they dedupe into a single sync rather than 500 of them.
- Survives failure. Jobs retry five times with exponential backoff. A trigger arriving mid-sync is never dropped.
- Flexible schema. The first sheet row is headers. You need an
idcolumn. Every other column is created in MySQL automatically, including ones added later. - Conflicts resolved and logged. When a row changes on both sides, the newer
updated_atwins. Ties go to the database. Every conflict is written toconflict_logsand broadcast live. - Measured. Edit-to-sync latency and rows per second are recorded for every run and exposed at
/api/metrics.
From npm run loadtest and the end-to-end suite, on a single container running MySQL 8, Redis 7 and Node 22:
| Workload | Result |
|---|---|
| Plan and write 10,000 new rows | 0.27s (about 37,000 rows/sec) |
| Plan and write 50,000 new rows | 0.96s (about 52,000 rows/sec) |
| Diff 50,000 rows (planner only) | 0.03s |
| Edit-to-sync latency, default settings | about 830ms p50 |
Edit-to-sync latency, SYNC_DEBOUNCE_MS=50 |
about 130ms p50 |
| Sync duration, small sheet | about 10ms |
Latency is dominated by the debounce window, which is the deliberate tradeoff that lets a burst of edits collapse into one sync. Lower SYNC_DEBOUNCE_MS for faster reaction, raise it to do less work under heavy editing.
Reproduce with:
cd server
npm run loadtest -- --rows 50000cp server/.env.example server/.env # fill in the Google Sheets values
docker compose up --buildDashboard on http://localhost:8080, API on http://localhost:4000/api/health.
This brings up MySQL, Redis, the API, a dedicated sync worker and nginx serving the dashboard.
cd server && npm install && cp .env.example .env # then fill it in
npm start # API plus inline worker
cd ../client && npm install && npm run devRedis is optional. Without REDIS_URL the server falls back to an in-process runner that coalesces triggers the same way, which is enough for local development.
The order that actually works end to end, running MySQL and Redis directly instead of in containers.
-
MySQL. Install MySQL 8 (MySQL Installer, Server component). Set a root password during setup. Then create the database:
CREATE DATABASE parity;
Everything else (tables, columns) is created automatically on first run.
-
Redis, via WSL. Windows has no native Redis build, so install it inside WSL:
wsl --installThen inside the Ubuntu shell it opens:
sudo apt update && sudo apt install -y redis-server sudo service redis-server start redis-cli ping # expect PONG
WSL2 shares
localhostwith Windows, soREDIS_URL=redis://localhost:6379in.envjust works. Redis does not survive a reboot automatically, runsudo service redis-server startagain each time. -
server/.env. Copyserver/.env.exampletoserver/.envand fill in: your MySQL password,DB_NAME=parity, the threeGOOGLE_*values from your service account's JSON key,REDIS_URL=redis://localhost:6379, and a generatedWEBHOOK_SECRET:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -
Start the app.
cd server && npm install && npm start cd client && npm install && npm run dev
Open the dashboard, click Sync Now, confirm the Sheet and MySQL panels match.
-
A public tunnel, so Google can reach your local server for push sync. Either works, no signup needed for the second:
ngrok http 4000 # or, with no account at all: ssh -R 80:localhost:4000 nokey@localhost.runCopy the
https://...forwarding URL it prints. It changes every time the tunnel restarts, so this has to be redone whenever that terminal is closed and reopened. -
Apps Script, see "Turning on push sync" below, using the tunnel URL from step 5 as
SYNC_WEBHOOK_URL. -
Verify both directions. Edit a cell in the Sheet, watch the server terminal log
sheet webhook acceptedthensync okwithin about a second. Then run anUPDATE ... SET updated_at = NOW()againstsynced_rowsin MySQL directly, and watch it appear in the Sheet withinDB_POLL_INTERVAL_MS.
- Create a Google Cloud service account, enable the Sheets API, and download a JSON key.
- Share your sheet with the service account's client email, as an Editor.
- Put
GOOGLE_SHEET_ID,GOOGLE_CLIENT_EMAILandGOOGLE_PRIVATE_KEYinserver/.env. Keep the\nescapes in the private key exactly as they appear in the JSON. - Give the sheet an
idcolumn. Anupdated_atcolumn is recommended, since conflict resolution uses it.
- Generate a secret and put it in
server/.envasWEBHOOK_SECRET:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" - In the sheet, open Extensions > Apps Script and paste in
server/apps-script/onEdit.gs. - In Project Settings > Script Properties add
SYNC_WEBHOOK_URL(your server's/api/webhook/sheet) andSYNC_WEBHOOK_SECRET(the same secret). - Run
installTrigger()once and accept the permission prompt.
It has to be an installable trigger. A plain onEdit(e) function runs unauthorized and is not allowed to make outbound requests, so it cannot reach your server. installTrigger() creates the right kind.
The server needs to be reachable from Google. For local development, tunnel it with ngrok or similar.
Every sync is a full reconcile rather than an incremental patch, which keeps it correct even if the server was down while edits happened.
- Read the sheet and the table.
computeSyncPlan()decides, per row id, what should happen. It is a pure function, which is why the interesting logic can be tested without either system running.- Apply the plan: chunked upserts, batched deletes, one sheet rewrite.
- Record the logs, the metric and the set of ids that existed after this sync.
That last set is what makes deletes work. A row present in the sheet but absent from the table is new if it was not in the last synced set, and deleted from the database if it was.
| Situation | Result |
|---|---|
| Only in sheet, not seen before | Insert into MySQL |
| Only in sheet, was synced before | Deleted in MySQL, so remove from sheet |
| Only in MySQL, not seen before | Add to sheet |
| Only in MySQL, was synced before | Deleted in sheet, so delete from MySQL |
| In both, checksums match | Nothing |
| In both, checksums differ | Newer updated_at wins, conflict logged |
Checksums are SHA-256 over the row's content, ignoring updated_at, so touching a row without changing it is not treated as an edit.
A sync rewrites the entire sheet, so two running concurrently would race and could resurrect rows the other just deleted. The worker runs at concurrency 1, and every trigger shares a single job id so pending triggers collapse into one.
Deduping alone would lose a trigger that arrives while a sync is already running, so triggers also set a dirty marker in Redis. The worker claims it when the job starts and re-queues on completion if it was set again. No edit is silently dropped.
99 tests, 56 unit and 43 integration.
cd server
npm run test:unit # no services needed
# integration and end-to-end, needs MySQL and Redis
DB_INTEGRATION=1 REDIS_URL=redis://localhost:6379 npm run test:integration- Unit covers the planner across every branch including a 10,000 row case, conflict tie-breaks and unparseable timestamps, checksum stability, header sanitization, and webhook authentication.
- Integration runs against real MySQL and Redis: schema, dynamic columns, chunked upserts, transaction rollback, queue dedupe and retry budget.
- End-to-end drives the real path from HTTP webhook through the queue, worker, planner, MySQL and back to the sheet, with only the Google Sheets API replaced by an in-memory sheet.
CI runs lint, unit tests, integration tests against service containers, the client build, the load test, and builds both Docker images.
| Endpoint | Purpose |
|---|---|
GET /api/health |
Liveness |
GET /api/data/sheet |
Headers and rows from the sheet |
GET /api/data/db |
Active rows from MySQL |
GET /api/sync/meta |
Last sync time and runner status |
POST /api/sync/force |
Queue a sync manually |
POST /api/webhook/sheet |
Apps Script edit notification, needs x-webhook-secret |
GET /api/metrics |
Latency percentiles, throughput, recent runs |
GET /api/metrics/activity |
Recent per-row sync log |
WS /ws |
Live status, sync_event and conflict_event |
Everything lives in server/.env, documented in server/.env.example. The ones worth knowing:
| Variable | Default | Effect |
|---|---|---|
REDIS_URL |
unset | Unset falls back to the in-process runner |
SYNC_DEBOUNCE_MS |
750 |
Burst collapsing window, the main latency lever |
SYNC_CHUNK_SIZE |
500 |
Rows per INSERT statement |
SYNC_JOB_ATTEMPTS |
5 |
Retries before a sync is marked failed |
DB_POLL_INTERVAL_MS |
5000 |
Database change check interval, 0 disables |
WEBHOOK_SECRET |
unset | Required for push sync, endpoint refuses without it |
RUN_WORKER_INLINE |
1 |
Set 0 when running a separate worker process |
parity/
├── docker-compose.yml MySQL, Redis, API, worker, dashboard
├── .github/workflows/ci.yml lint, tests, load test, image builds
├── client/ Vite + React dashboard, nginx image
└── server/
├── apps-script/onEdit.gs paste into the sheet
├── config/ env, MySQL pool, Google auth, Redis
├── queue/ BullMQ queue, worker, job processor
├── services/
│ ├── syncPlanner.js pure diff logic, the testable core
│ ├── syncEngine.js reads, plans, applies, measures
│ ├── changeDetector.js MySQL-side polling
│ ├── runner.js queue or in-process, chosen by config
│ ├── sheetService.js Google Sheets read and write
│ └── dbService.js chunked, batched MySQL access
├── scripts/loadtest.js throughput benchmark
└── tests/ unit, integration, end-to-end
Node.js, Express, BullMQ on Redis, MySQL 8, Google Sheets API, WebSockets, React, Tailwind, Docker Compose, GitHub Actions.