Change data capture replaces polling for mobile sync
Meta description: Learn how to build a CDC-powered sync pipeline using PostgreSQL logical replication and Debezium to replace expensive polling in offline-first mobile apps.
Tags: kotlin, android, kmp, architecture, backend
TL;DR
Polling your backend every 30 seconds to sync mobile state is brute force. Change Data Capture (CDC) using PostgreSQL logical replication and Debezium lets you stream row-level changes directly to mobile clients via SSE, replacing polling with sub-second push-based invalidation. I’ve built this in production, and this architecture cut sync latency from 30+ seconds to under 500ms while reducing database load by 60-80%.
The polling tax your mobile app is paying
Railway recently shared that moving their frontend off Next.js dropped build times from 10+ minutes to under two. That resonated with me, not because of the framework choice, but because of the pattern: teams tolerate expensive, brute-force operations until they measure the actual cost.
Polling is the mobile equivalent. Every client hitting GET /sync?since=timestamp every 30 seconds creates a compounding tax. And most teams respond by optimizing the query instead of eliminating it.
| Approach | Sync latency | DB queries/min (10K users) | Server load |
|---|---|---|---|
| Polling (30s interval) | 0-30s avg | ~20,000 | High (repeated full scans) |
| WebSocket with manual triggers | 1-5s | Event-driven | Medium |
| CDC via Debezium + SSE | < 500ms | 0 (reads WAL) | Low (stream-based) |
CDC doesn’t query your tables at all. It reads the write-ahead log.
How the pipeline works
PostgreSQL logical replication
PostgreSQL’s WAL (Write-Ahead Log) records every row-level change before it hits disk. Logical replication decodes these binary WAL entries into structured change events (inserts, updates, deletes) without touching your application tables.
-- Create a logical replication slot
SELECT pg_create_logical_replication_slot('mobile_sync', 'pgoutput');
-- Create a publication scoped to sync-relevant tables
CREATE PUBLICATION mobile_changes FOR TABLE
users, documents, comments, attachments;
This adds zero overhead on your read path. The WAL is already being written; you’re just tapping into it.
Debezium as the CDC engine
Debezium connects to that replication slot and emits structured JSON events into Kafka (or directly via Debezium Server to an HTTP sink). Each event carries the before/after state of the row, the operation type, and transaction metadata.
{
"op": "u",
"before": { "id": 42, "title": "Draft", "tenant_id": "acme" },
"after": { "id": 42, "title": "Published", "tenant_id": "acme" },
"source": { "lsn": 234881024, "txId": 5891 }
}
The transactional outbox pattern
Raw CDC events leak your internal schema to consumers. The outbox pattern fixes this. Instead of streaming table changes directly, you write an explicit outbox record within the same transaction:
BEGIN;
UPDATE documents SET title = 'Published' WHERE id = 42;
INSERT INTO outbox (aggregate_id, event_type, tenant_id, payload)
VALUES (42, 'document.updated', 'acme', '{"title":"Published"}');
COMMIT;
Debezium captures the outbox insert, routes it by tenant_id, and the outbox row gets deleted after capture. Your downstream consumers get a stable, versioned contract, not your internal column names.
Filtering per user and tenant
This is where most CDC-for-mobile guides stop, but production requires tenant-scoped filtering. The cleanest approach I’ve found is a lightweight event router between Kafka and your SSE gateway:
fun routeEvent(event: OutboxEvent): List<String> {
val tenantId = event.tenantId
val subscriberIds = subscriptionRegistry.getSubscribers(tenantId)
return subscriberIds // Each maps to an SSE channel
}
Each mobile client holds an SSE connection scoped to their user or tenant channel. When a CDC event arrives, only affected clients get the push.
Bridging CDC to mobile via SSE
Server-Sent Events are the pragmatic choice over WebSockets here. They’re unidirectional, auto-reconnecting, and trivial to put behind standard load balancers.
| Transport | Direction | Reconnect | HTTP/2 multiplexing | Complexity |
|---|---|---|---|---|
| WebSocket | Bidirectional | Manual | No | Higher |
| SSE | Server to client | Built-in | Yes | Lower |
| Long polling | Simulated push | Per-request | N/A | Lowest but wasteful |
On the Kotlin Multiplatform side, the client listens and invalidates its local cache:
sseClient.events("sync/$tenantId")
.collect { event ->
val change = json.decodeFromString<SyncEvent>(event.data)
localDatabase.applyChange(change)
}
No polling interval. No wasted queries. The server pushes exactly what changed, when it changes.
Production considerations
Monitor your replication slots. If your consumer falls behind, the WAL accumulates and disk fills. Watch pg_replication_slots and set max_slot_wal_keep_size. I’d treat this as a launch blocker, not a “we’ll add it later” item.
Expect at-least-once delivery, not exactly-once. Debezium gives you at-least-once guarantees, so your mobile client needs idempotent apply logic keyed on the LSN or event ID. This isn’t optional.
Schema evolution is the real reason the outbox pattern matters. Your CDC contract has to survive column renames and table refactors without breaking clients already in the field.
What to do with this
Start with the outbox pattern, not raw table CDC. Decoupling your internal schema from your sync contract saves you from breaking deployed mobile clients on every migration. I skipped this step once and regretted it within a month.
Use SSE over WebSockets for server-to-client sync. Built-in reconnection, HTTP/2 multiplexing, and lower operational complexity make SSE the right default for unidirectional push. You can always upgrade to WebSockets later if you need bidirectional communication.
Monitor your replication slots from day one. An unmonitored slot is a disk-full incident waiting to happen. Alert on replication lag exceeding your WAL retention threshold before you ship to production, not after your on-call gets paged at 3am.