MVP Factory
ai startup development

Change data capture replaces polling for mobile sync

KW
Krystian Wiewiór · · 5 min read

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.

ApproachSync latencyDB queries/min (10K users)Server load
Polling (30s interval)0-30s avg~20,000High (repeated full scans)
WebSocket with manual triggers1-5sEvent-drivenMedium
CDC via Debezium + SSE< 500ms0 (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.

TransportDirectionReconnectHTTP/2 multiplexingComplexity
WebSocketBidirectionalManualNoHigher
SSEServer to clientBuilt-inYesLower
Long pollingSimulated pushPer-requestN/ALowest 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.


Share: Twitter LinkedIn