Database Sync & Consistency Patterns

Maintaining financial accuracy across a fleet of billing microservices is where most subscription systems quietly leak revenue. When a payment gateway emits an asynchronous event, the billing scheduler, the tax engine, the entitlements service, and the customer portal must all converge on the same truth — without violating ACID guarantees on the ledger or introducing drift between read replicas. These database sync and consistency patterns are the connective tissue of Webhook Processing & Backend State Management: they decide which writes must be strongly consistent, how out-of-order events are ordered, and how local state is reconciled against the provider. Get the boundaries wrong and you get duplicate invoices, suspended-but-paying customers, and reconciliation reports that never balance.

The framing that makes these decisions tractable is to stop asking “how do I keep everything consistent?” and start asking “what is the smallest set of data that must be strongly consistent, and what can I let converge?” The CAP theorem forces a choice under partition, but that choice does not have to be global — it can be made per data class. The ledger and the authoritative subscription state pick consistency: a stale read there means a double charge or a wrongful suspension, so they must reject a write rather than serve a stale one. Analytics, search indexes, notification queues, and UI caches pick availability: a slightly stale seat count on a dashboard harms no one and heals in seconds. Almost every hard consistency bug in a billing system traces back to a team that accidentally put a money-affecting decision on the availability side of that line, or paid the latency cost of strong consistency for data that never needed it. Draw the line deliberately, write it down, and make it visible in the schema — the strongly-consistent tables in one place, the read models fed by the outbox in another.

Prerequisites

Cross-service consistency is assembled from primitives: optimistic concurrency for the strong boundary, an outbox for reliable fan-out, idempotency for safe replay, a broker with a DLQ, and a reconciliation job for provider truth. The stack shows the dependencies before the checklist.

Sync prerequisites A versioned table, an outbox, an idempotency store, a broker with a DLQ, and a reconciliation job underpin cross-service consistency. Cross-service consistency Versioned row optimistic lock Outbox reliable fan-out Idempotency safe replay Broker + DLQ bus Reconcile job provider truth
Five primitives assemble cross-service consistency — the versioned row and the outbox are the load-bearing pair.

The UTC requirement in that last checkbox is not pedantry — it is a correctness prerequisite that bites during exactly the two moments a billing system is most exercised: daylight-saving transitions and month boundaries. If any billing timestamp is stored in local time, a grace period that should last 72 hours becomes 71 or 73 across a DST change, a current_period_end computed in one service’s timezone disagrees with the same instant in another’s, and events sort into the wrong order when two services compare timestamps drawn from different clocks. Normalize every timestamp to UTC at the moment of write, store the customer’s display timezone as a separate presentation-only field, and convert to local time only at render. The saga and compensation machinery described here assumes a single, total ordering of events in time; the moment two services disagree about what time it is, that ordering fractures and the whole consistency argument weakens. A one-line “always UTC” rule enforced at the storage boundary is the cheapest insurance in the entire subsystem.

Sagas deserve a word because cross-service billing operations frequently cannot be a single transaction. Provisioning a new subscription might touch billing, entitlements, and a third-party fulfillment API — three systems that cannot share one ACID boundary. The saga pattern models this as a sequence of local transactions, each emitting an event that triggers the next, with an explicit compensating action for each step that can undo it if a later step fails. If fulfillment fails after billing succeeded, the compensation reverses the charge (a credit note, never a delete) rather than leaving a customer billed for something they never received. Designing the compensations up front — and making them idempotent, since a compensation can itself be retried — is what lets a multi-service operation fail safely instead of stranding the customer in a half-provisioned state.

The idempotency store in that checklist earns its place because it is what makes at-least-once delivery survivable. Every consumer downstream of the outbox will, eventually, see the same event twice — a poller republishes after a crash between publish and acknowledgement, the broker redelivers on a consumer timeout, a reconciliation override re-emits a change. The defence is a deterministic key that identifies the effect, not the delivery: derive it from the provider’s event id joined to the target aggregate, for example sha256(provider_event_id + ':' + subscription_id), and record it under a unique constraint in the same transaction that applies the effect. On redelivery the insert violates the constraint, the handler catches the conflict and returns the previously computed result, and no second ledger row is written. What breaks this is keying on anything the retry does not preserve — a wall-clock timestamp, an auto-increment id, a random request id minted per delivery — because then the second delivery computes a different key and slips past the guard. The key must be a pure function of the business event, and it must be written transactionally with the effect it guards, or it guards nothing.

Architecture & Data Flow

A webhook is verified and deduplicated, then its effect is written transactionally alongside an outbox row. A poller (or change data capture) drains the outbox to the bus, where downstream services build their own read models. A reconciliation job periodically diffs local state against the provider and applies provider-truth overrides. The inputs are unordered provider events; the outputs are convergent, auditable read models across every service.

The reason this shape beats the obvious alternative — having each service call each other synchronously to stay in sync — is that synchronous cross-service calls turn every service’s availability into every other service’s availability. If the entitlements service must call the billing service on every request, a billing deploy becomes an entitlements outage. The outbox-and-bus shape inverts that dependency: services never call each other on the hot path; they subscribe to a durable event stream and build local read models they own entirely. Now the entitlements service answers requests from its own database at its own latency even while billing is mid-deploy, and it catches up on missed events when the stream resumes. The trade is that read models are eventually consistent — an entitlement might lag a state change by the poller’s cycle time — which is exactly why the strong core exists for the decisions that cannot tolerate that lag. Change data capture (CDC) is an alternative to a polling relay for draining the outbox: instead of a poller querying for pending rows, CDC tails the database’s write-ahead log and emits a change event per committed row, trading operational complexity for lower latency and less load on the primary.

Ordering on the bus only holds within a partition, and that constraint dictates the partition key. Kafka guarantees order per partition, not per topic, so if events for one subscription_id land in different partitions their relative order is lost — a subscription.canceled can overtake the subscription.updated that preceded it, and a consumer that applies them in receipt order corrupts state. Partition by subscription_id (or customer_id where the aggregate is the customer) so that every event for one aggregate is totally ordered on a single partition, and accept that events for different subscriptions may interleave freely, which is harmless because they touch disjoint state. This is also why the monotonic version guard is not redundant with partition ordering: partitioning gives you order at the transport layer, but a consumer replaying from an earlier offset after a rebalance will re-see old events, and only the version guard makes that replay a no-op rather than a regression. Deletes need the same care — never let a hard delete propagate as an absence of events. Emit an explicit tombstone (subscription.deleted carrying the final version) so downstream read models can converge on “gone” rather than silently retaining a row no upstream event will ever touch again.

Outbox-driven sync across services A single transaction writes subscription state and an outbox row; a poller fans events out to tax, entitlements, and portal services, while a reconciliation job repairs drift. Webhook tx state + outbox Outbox poller / CDC → bus Tax engine Entitlements Customer portal Reconcile job (provider diff)
One transaction writes state and an outbox row; the poller fans out, and a reconciliation job repairs any drift against provider truth.

Implementation Walkthrough

The four steps set the boundary, write atomically, order events, and reconcile. The key idea is a two-tier consistency model: a strong core (ledger and state row) guarded by optimistic concurrency, surrounded by eventually-consistent read models fed by the outbox. The diagram shows the two tiers.

Two-tier consistency model A strong core of ledger and subscription state uses optimistic concurrency; surrounding read models for tax, entitlements, and UI are eventually consistent via the outbox. Strong core ledger + state · version guard Tax read model Entitlements Portal cache Analytics core = strong · everything else = eventual, fed by the outbox
Reserve strong consistency for the core; let read models be eventually consistent behind the outbox.

1. Pick the consistency boundary

Strong consistency across every service is neither achievable nor necessary. Reserve it for the ledger and the subscription state row; let analytics, notifications, and UI caches be eventually consistent. Enforce the strong boundary with optimistic concurrency on the version column.

Optimistic concurrency wins over pessimistic row locks here because billing writes are contended rarely but expensively. Two events for the same subscription arriving simultaneously is uncommon per subscription, but when it happens under a pessimistic lock the second writer blocks — and if that lock is held across a slow gateway call, it stalls a worker and can cascade. The optimistic approach lets both writers proceed, and the version < :event_version guard means the older event’s UPDATE simply affects zero rows, at which point the handler treats it as stale and drops or re-queues it. There is no lock to hold, no worker to block, and the loser learns it lost by reading affected_rows. The one discipline this demands is that the version must come from the event’s logical sequence, not a local version + 1, because a local increment cannot distinguish “newer event” from “event I happened to process second” — and it is newer-ness, not processing order, that must win.

-- Optimistic concurrency: the WHERE clause is the lock.
UPDATE subscriptions
SET    state      = :target_state,
       version    = :event_version,
       updated_at = now()
WHERE  subscription_id = :subscription_id
  AND  version < :event_version;            -- ✅ only newer events win

-- If affected_rows = 0, the event was stale or out of order: drop or queue it.

2. Write state and outbox in one transaction

The atomic write of domain change plus event is what makes downstream sync reliable. Never call the broker inside this transaction.

The reason this rule is absolute deserves spelling out, because “just publish after the commit” feels equivalent and is not. Consider the two orderings. If you publish before commit and the commit then fails, you have announced a state change that never happened — downstream services grant entitlements for a subscription that does not exist. If you publish after commit and the process crashes in the gap between the two, the state changed but the event never fired — downstream services never learn, and the subscription is silently out of sync forever. There is no ordering of two separate operations that is safe, because a crash can always land in the gap. The outbox dissolves the problem by making the publish-intent part of the same transaction as the state change: the event is not published yet, but the record that it must be published is now as durable as the state change itself, committed or rolled back together. A separate relay then publishes from that durable record with at-least-once delivery. This is the entire reason the pattern exists, and it is why “optimizing” the relay away by publishing inline reintroduces exactly the dual-write bug it was built to kill.

def apply_webhook(sub_id: str, target_state: str, event_version: int, payload: dict) -> str:
    with db.transaction():
        rows = db.execute(
            "UPDATE subscriptions SET state=%s, version=%s, updated_at=now() "
            "WHERE subscription_id=%s AND version < %s",
            target_state, event_version, sub_id, event_version,
        )
        if rows == 0:
            return "IGNORED_STALE"                      # ⚠️ out-of-order, no-op
        db.execute(
            "INSERT INTO billing_outbox (aggregate_id, event_type, payload) "
            "VALUES (%s, %s, %s)",
            sub_id, f"subscription.{target_state}", Json(payload),
        )
    return "APPLIED"                                    # ✅ committed atomically

3. Order events and fill gaps

Providers deliver out of sequence. Buffer events that arrive ahead of the expected sequence, apply contiguous ones, then flush the buffer.

Gap-filling needs a timeout, or a permanently missing event stalls a subscription forever. If sequence 5 arrives but 4 never does — the provider dropped it, or it is stuck in a retry — you cannot buffer 5 indefinitely. Bound the wait: hold the gap for a grace window, and if it does not fill, actively fetch the missing sequence from the provider’s API rather than waiting for a redelivery that may never come. This converts a passive “hope it arrives” into an active “go get it,” which is the difference between a subscription that self-heals in seconds and one that silently freezes. For state transitions where strict ordering does not actually matter — two independent metadata updates, say — you can skip buffering entirely and let the monotonic version guard sort them out, reserving the buffering machinery for sequences where applying out of order would corrupt state (a cancel before the create it depends on).

class OrderingEngine:
    def process(self, event: dict) -> str:
        expected = self.store.last_sequence(event["subscription_id"])
        if event["sequence"] <= expected:
            return "DUPLICATE_OR_STALE"                 # already applied
        if event["sequence"] > expected + 1:
            self.buffer.push(event)                     # gap: hold until filled
            return "PENDING_GAP_FILL"
        self.apply(event)
        self.store.set_sequence(event["subscription_id"], event["sequence"])
        self.flush_buffer(event["subscription_id"])     # ✅ release contiguous events
        return "APPLIED"

4. Reconcile against the provider

Schedule a job that re-fetches subscriptions modified since the last run, diffs them against the local ledger, and applies provider-truth overrides with an immutable audit entry. Shard by tenant and respect Retry-After to avoid provider rate limits.

Reconciliation is the safety net that makes the whole eventually-consistent design defensible: it assumes the event stream will occasionally lose or mis-order an event and repairs the result before it reaches a financial statement. The critical design choice is which fields the provider is authoritative for versus which you own. The provider owns lifecycle facts it originates — status, cancel_at_period_end, current_period_end — so on a conflict there, provider truth wins and you overwrite local state. You own internal facts the provider never sees — feature flags, internal risk scores, entitlement grants — so those are never touched by reconciliation. Every override must land as its own audit event (provider_override), not a silent update, because a subscription that keeps needing overrides is a signal that an upstream consumer has a bug, and you can only see that pattern if the overrides are recorded. Reconciliation that silently self-heals hides the very bugs it should be surfacing.

-- Candidates for reconciliation: anything not synced recently.
SELECT subscription_id
FROM   subscriptions
WHERE  updated_at < now() - INTERVAL '2 hours'
ORDER  BY updated_at
LIMIT  500;

Edge Cases & Failure Modes

The consistency failures divide by where they originate: event ordering, poller health, or the provider relationship. Each has a distinct containment — versioning and buffering for ordering, a lag health-check for the poller, and provider-truth overrides plus rate-limit respect for the provider. The map sorts them.

Sync failure origins Ordering failures need versioning and buffering, poller lag needs a health-check that halts downstream mutations, and provider conflicts need truth overrides and rate-limit respect. Ordering out-of-order events concurrent upgrades → version + buffer Poller health lag / split-brain hours behind → halt on lag threshold Provider rate limits state conflict → override + Retry-After
Three failure origins, three containments — versioning, a lag health-check, and provider-truth overrides.
Failure scenario Root cause Mitigation
Out-of-order webhook delivery Network routing variance Monotonic versioning + sequence buffering + compensating transactions
Concurrent plan upgrades Race in the state machine Optimistic concurrency (version < :event_version) + idempotency keys
Mid-cycle tax jurisdiction change Provider API latency Historical rate versioning with effective-date ranges; retroactive ledger adjustment job
Outbox poller lag (split-brain) Consumer falls hours behind Health check halts downstream mutations when lag exceeds threshold
Provider rate limits during bulk sync Throttling at peak renewal Token-bucket limiter, shard by tenant, honour Retry-After, exponential backoff
GDPR deletion vs audit retention Conflicting mandates Pseudonymise PII; preserve immutable financial archive under legal hold

The row worth dwelling on is the mid-cycle jurisdiction change, because it exposes how consistency and history intertwine. When a customer moves and their tax jurisdiction changes partway through a billing period, the naive fix — update their jurisdiction and move on — silently makes every prior invoice in that period unreproducible, because a later re-derivation now uses the new jurisdiction. The correct handling treats jurisdiction like any other time-bounded fact: the invoice pins the jurisdiction that was true on its supply date, and the change takes effect prospectively. This is the same append-only discipline the ledger uses, applied to reference data, and it is why “just update the row” is almost always the wrong instinct in a billing system — the current value and the historical value are different questions, and a system that keeps only the current one has thrown away its own auditability. Every mitigation in the table shares this shape: detect the anomaly, contain it without mutating history, and record the containment as its own durable fact.

A second-order failure the table hints at but deserves emphasis is the silent version of each row. An out-of-order webhook that gets dropped rather than buffered, a poller that falls behind without alerting, a provider override that never fires because reconciliation is not running — none of these throw an error. They degrade correctness quietly, and the first visible symptom is a customer complaint or a reconciliation report that will not balance weeks later. This is why every mitigation pairs a mechanism with a signal: the buffer has a gap-fill timeout that alerts, the poller has a lag threshold that pages, reconciliation counts and reports its overrides. In a system whose failures are silent by nature, the monitoring is not an add-on to the pattern — it is half of the pattern.

Performance & Scale

The outbox poller is the throughput-critical path, so its query must stay cheap under a renewal-day burst: a partial index limits the scan to pending rows, SKIP LOCKED lets multiple pollers run without contention, and monthly partitions keep indexes hot. The diagram shows the poller’s fast path.

Outbox poller scale A partial index on pending rows, SKIP LOCKED for parallel pollers, and monthly partitioning keep the poller scan cheap under a renewal burst. Partial index WHERE pending SKIP LOCKED parallel pollers Monthly partitions hot indexes
Three techniques keep the poller's working set small — a partial index, SKIP LOCKED, and partitioning.

The outbox poller is the throughput-critical path. Keep a partial index (WHERE status = 'pending') so the poller’s scan touches only unpublished rows, and claim batches with FOR UPDATE SKIP LOCKED so multiple poller instances do not contend. Partition webhook_events and billing_outbox by month to keep indexes hot and make retention drops cheap. For cross-region read models, use logical replication slots for slowly-changing reference data (tax rate tables) and enforce read-after-write only on checkout endpoints where it matters. Cache provider-status reads from reconciliation with a short TTL so retries do not re-hit the provider API.

One scaling failure worth naming specifically is the connection-pool interaction between the poller and the transactional writers. A poller running FOR UPDATE SKIP LOCKED holds a row lock and a connection for the duration of its publish batch; run too many poller instances with too large a batch and they exhaust the same pool the webhook handlers need to commit their subscriptions and billing_outbox writes. The symptom is counterintuitive — throughput collapses precisely when volume is highest, because the drainers starve the writers that feed them. Cap poller concurrency well below the pool size, keep the publish batch small enough that a connection is held for tens of milliseconds not seconds, and publish to the broker outside the row-lock window where the relay design allows it. Treat the connection pool as the shared resource it is: the writers must always win contention for it, because a stalled writer drops a customer-facing event while a stalled poller merely adds latency the reconciliation job will later paper over.

The number that determines whether this design survives a renewal spike is poller lag under burst, and it is worth load-testing explicitly rather than hoping. On the first of the month a monthly-billing product may fire a large fraction of its renewals within a narrow window, and each renewal writes an outbox row; if the poller drains slower than rows arrive, lag grows without bound and downstream read models fall arbitrarily far behind. Size the poller’s throughput (batch size × poll frequency × parallel instances) against the peak insert rate with headroom, and alert on lag as a leading indicator — lag climbing is the early warning that arrives before any read model is visibly stale. Two cheap wins compound here: keep the outbox row small (an aggregate ID and event type, with the full payload fetched by consumers on demand) so each publish is fast, and prune published rows aggressively so the partial index the poller scans never bloats. A poller that is fast on a quiet Tuesday but collapses on the first of the month has not been tested on the day that matters.

Testing Strategy

The suite proves four invariants: scrambled order converges to one state, replay is idempotent, a provider 503 trips the breaker rather than corrupting state, and the version number only ever increases. The panel lists them before the detail.

Sync test invariants Scrambled-order convergence, replay idempotency, breaker-trips-on-503, and a monotonic version property test. Convergence scrambled order same final state Replay same event id one effect 503 breaker provider down backs off clean Monotonic any interleaving version only up
Four invariants — the convergence and monotonic-version properties are the ones that catch subtle races.

Determinism is everything here. Drive the ordering engine with a fixed sequence of events delivered in scrambled order and assert the final state is independent of arrival order. Inject a mock clock so grace-period and current_period_end boundaries are reproducible. Replay the same provider_event_id twice and assert exactly one ledger effect (idempotency replay test). Simulate a provider 503 and assert the circuit breaker trips and the reconciliation job backs off rather than corrupting state. Finally, run a property test that, for any interleaving of concurrent updates, the version invariant version only ever increases.

The convergence property is the one to invest in, because it is where the subtle bugs live and where hand-written example tests give false confidence. A convergence test generates a set of events for one subscription, then feeds every permutation (or a large random sample of permutations) of their arrival order through the pipeline and asserts the final state is identical every time. This is a property test, not an example test, precisely because the failure mode is “one specific unlucky ordering corrupts state” — the kind of ordering a hand-picked test case will never think to try but production sees within a week. Seed the generator deterministically so a failure reproduces exactly, and shrink to the minimal failing permutation so the bug is legible. Pair it with a fault-injection layer that randomly drops, delays, and duplicates events, and assert that reconciliation still brings the system to the correct final state — that test proves the safety net actually catches what the stream drops, which is the whole premise the eventually-consistent design rests on.

Frequently Asked Questions

How do you handle out-of-order webhooks without breaking financial compliance? Stamp each subscription with a monotonic version sourced from the provider sequence, buffer events that arrive ahead of the expected sequence, and discard those that arrive behind it. Apply compensating transactions for late arrivals and keep an immutable audit log so every reordering decision is reconstructable for auditors.

When should a billing system use strong consistency over eventual consistency? Strong consistency is mandatory for ledger entries, tax snapshots, and dunning state transitions, because a double charge or wrongful suspension carries legal and financial liability. Eventual consistency is fine for analytics, notification routing, and UI caches that do not influence billing decisions.

What database constraints prevent ledger drift during renewal spikes? Foreign-key constraints on invoice-to-ledger mappings, optimistic concurrency via a version column, unique idempotency keys on every mutation endpoint, and a nightly reconciliation job that compares provider settlement reports against internal logs. Together these make a duplicate or stale write a detectable no-op rather than silent corruption.

Should I trust the provider or my local ledger during a conflict? Defer to provider truth for status, cancel_at_period_end, and trial_end; keep your local ledger authoritative for internal metadata and user-facing flags that do not affect billing. Record every override as a provider_override audit event.

Outbox polling or change data capture — which should I use? Start with a poller: a SELECT ... FOR UPDATE SKIP LOCKED over a partial index on pending rows is simple, easy to reason about, and runs anywhere Postgres runs. Move to CDC (tailing the write-ahead log) when polling latency or its load on the primary becomes the constraint — CDC delivers changes with lower latency and no repeated scans, at the cost of running and monitoring a log-tailing pipeline. Both feed the same downstream contract, so the migration is transparent to consumers; it is purely an operational trade of simplicity for latency.

How do I keep read replicas from serving stale data on checkout? Route the few endpoints that genuinely need read-after-write — the moment right after a checkout, a plan change confirmation — to the primary, and let everything else read from replicas. Trying to make every read strongly consistent throws away the whole point of replicas; the trick is identifying the small set of reads where a customer would notice their own just-made change missing, and pinning only those to the primary.

How do sagas fit with the outbox pattern? They compose cleanly: each local transaction in the saga writes its state change and an outbox row atomically, and the published event is what advances the saga to its next step. The outbox guarantees the “next step” event is never lost even if the process crashes after the local commit, which is exactly the reliability a multi-step saga needs. Model each step’s compensating action as another event-driven local transaction so that undo is as durable and idempotent as the forward path — a compensation that can be dropped or double-applied is worse than no compensation at all.

What is the single most common cause of ledger drift in practice? A dual write — code that updates the database and then, as a separate step, calls a broker, an external API, or a cache. Under normal conditions it works, which is why it survives code review; under a crash in the gap between the two writes it silently desynchronizes, and the drift is discovered weeks later at reconciliation. Almost every “our numbers don’t match” incident traces back to a dual write that should have been an outbox row in the same transaction. Auditing the codebase for “database write immediately followed by a network call” finds them before they find you.

How do I partition the event stream so ordering holds per subscription? Use the aggregate identity as the partition key — subscription_id for subscription-scoped events, customer_id where the customer is the aggregate. Order is guaranteed only within a partition, so co-locating every event for one aggregate on one partition gives you the total order that state transitions depend on, while events for different aggregates interleave harmlessly on other partitions. Avoid keying on anything higher-cardinality-per-aggregate (an invoice_id when the state you mutate is the subscription) or you split one aggregate’s history across partitions and lose the ordering the version guard then has to clean up after.

What retention should the outbox and event tables have? Prune published outbox rows aggressively — once the relay has confirmed delivery and the row is past any redelivery window, it is dead weight that bloats the partial index the poller scans, so a job that deletes published rows older than a few hours keeps the working set tiny. The webhook_events audit table is different: it is your reconstruction record, so keep it as long as financial retention rules demand, but move it off the hot path by partitioning monthly and detaching old partitions to cold storage. The distinction is that the outbox is a queue that should stay near-empty, while the event log is an archive that should stay complete — conflating their retention policies either starves your audit trail or bloats your queue.

Can I skip the outbox and just use the database’s transactional listen/notify? Only for low volume. Postgres LISTEN/NOTIFY fires within the committing transaction, which tempts you to treat it as a free outbox, but the notification is not durable — a listener that is disconnected at commit time never receives it, and there is no backlog to replay. It also delivers no payload of any size and collapses under high notify rates. Use it as a low-latency wake-up for the poller (“rows are waiting, drain now”) layered on top of the durable outbox, never as the delivery mechanism itself. The outbox row is the source of truth; the notify is just an optimization that shaves poll latency when the system is healthy.