Architecting Next-Generation E911: Separating Real-Time Location Delivery from Provisioning

Emergency location delivery has an unusual architectural constraint: the workload that writes location data is not the workload that serves it. Provisioning is transactional, validation-heavy, and bursty. Location delivery must stay predictable and low-latency the moment an emergency lookup arrives. Putting both workloads behind the same database creates a coupling that is difficult to reason about under load, and a write-heavy provisioning spike can degrade the read path exactly when it cannot afford to.

This post is written for engineers and engineering managers building latency-sensitive, regulated systems. As part of an NG911 (Next-Generation 911) platform initiative at a major telecommunications provider, I took technical ownership across the provisioning and location-delivery paths and helped shape an architecture that separates them using log-based change data capture (CDC), while still meeting NENA i3 (HELD, PIDF-LO) and FCC compliance requirements.

Note: This post describes the architecture and engineering practices behind an NG911 platform initiative, generalized and with the employer and internal program name anonymized. The legacy data migration discussed below is in preparation. It has not yet run in production.


TL;DR


System at a Glance

Aspect Detail
Compliance requirements NENA i3 (HELD, PIDF-LO), FCC mandates
Read path latency target p95 under 100 ms, measured at the location service boundary
Migration scope Millions of legacy location records, not yet migrated
Team A small cross-functional engineering and QA group spanning both the provisioning and location-delivery paths

Consolidating Ownership Across the Write and Read Paths

I joined the initiative as a technical leader focused on the location-delivery read path: the APIs that serve downstream VoIP routing networks during an active emergency call. A sibling team owned the provisioning write path, the system that validates and persists device and tower location data.

A few months in, leadership asked me to take broader technical ownership across both the provisioning and location-delivery paths, given upcoming FCC compliance deadlines and the need for one coherent architectural roadmap. I now direct a small cross-functional engineering and QA team spanning both paths.

The consolidation removed a coordination cost that had been slowing both teams down. With one team owning both sides of the boundary, we could align API contracts, unify testing governance, and commit to a single architectural direction instead of negotiating one across a team boundary. The delivery timeline moved forward as a result, rather than stalling during the transition.


The Architectural Core: Separating Writes from Reads

Provisioned 911 location data flowing through the platform to the PSAP

In emergency response, a spike in write-heavy location provisioning must never degrade read performance during an active 911 lookup. The two workloads have fundamentally different requirements: provisioning needs strict transactional consistency and auditability, and lookup needs to be fast and available under load. These requirements do not coexist well on shared infrastructure, so the platform separates them.

Redis is the serving boundary between the two paths below: the provisioning path updates the database, CDC propagates those changes into Redis, and the location service reads from Redis.

Thesis: the transactional system owns persistence and consistency, the location service owns low-latency serving, and CDC connects the two without introducing application-level dual writes. A separate audit-replication loop feeds Redis-side audit data back into the database, so the compliance record of what was served is durable even though the read path itself never writes to the database directly.

Provisioning (write path)

---
config:
  look: neo
---
flowchart TD
    PA["Actor<br/>provisions location data"] -- provisions location data --> PS["Provisioning Service"]
    PS -- persists data to --> DB[("DB<br/>location data")]
    DB -- pulled by --> CDC["CDC / Debezium"]
    CDC -- hands off to --> RDI["Redis DI"]
    RDI -- pushes data to --> REDIS[("Redis")]
    ARS["Audit Replication<br/>System"] -- fetches audit data<br/>published by location service --> REDIS
    ARS -- publishes audit records to --> AUDITDB[("DB<br/>audit log")]
    AUDITDB -. not pulled by CDC<br/>not replicated to Redis .-x REDIS

    classDef noflow stroke:#c0392b,color:#c0392b,stroke-dasharray:4 3;
    class AUDITDB noflow;
    linkStyle 7 stroke:#c0392b,stroke-width:2px,stroke-dasharray:4 3;

Location delivery (read path)

---
config:
  look: neo
---
flowchart TD
    PSAP["Actor: PSAP<br/>receives distress call"] -- fetches location data --> LS["Location Service"]
    LS -- fetches data --> REDIS[("Redis")]
    LS -- publishes audit data --> REDIS

Decision: synchronizing the two paths

---
config:
  look: neo
---
flowchart TD
    A["Application dual-write<br/>DB + Redis"] --> A2["Partial failure /<br/>inconsistency"]
    B["Read-through fallback<br/>Redis, DB on miss"] --> B2["Database contention<br/>under load"]
    C["Log-based CDC<br/>DB to CDC to Redis"] --> C2["Independent read/write<br/>workloads"]

    classDef reject stroke:#c0392b,color:#c0392b,stroke-dasharray:4 3;
    classDef accept stroke:#2e7d32,color:#2e7d32,stroke-width:2px;
    class A2,B2 reject;
    class C2 accept;

Options considered:

  1. Synchronous dual writes from the application layer to both the database and Redis on every provisioning request.
  2. Synchronous read-through, where the location service reads from Redis and falls back to the database on a cache miss.
  3. Asynchronous change data capture, reading mutations directly off the database’s redo logs and propagating them to Redis out of band.

Tradeoffs:

Decision: asynchronous CDC, reading mutations from the database redo logs.

Implication: replication lag becomes a first-class metric with its own monitoring and alerting. It also means the validation strategy for any bulk operation against the database, including the migration described below, has to explicitly verify that changes have propagated to Redis, not only that they landed in the database.

Change data capture: Debezium today, RDI under evaluation

Aspect Before: Debezium (current production) After: Redis Data Integrator, RDI (in evaluation)
Change capture mechanism Log-based connector translating database changes into events consumed by a downstream sync process Log-level mapping directly from relational tables to Redis data structures
Intermediate hops Connector plus a downstream consumer/transform step Fewer intermediate hops between the redo log and Redis
Sync latency Current production baseline Expected improvement; not yet finalized

Options considered: remain on Debezium, adopt RDI, or build a custom log-tailing service.

Tradeoffs: Debezium is mature and well understood but carries connector and downstream-consumer overhead. RDI allows changes to be mapped directly into Redis data structures, potentially reducing intermediate processing compared with the current pipeline, but that improvement still needs to be demonstrated under representative production workloads, and RDI is newer in our environment and less proven at our scale. A custom log-tailing service would offer the most control but the highest engineering and operational cost to build and maintain.

Decision: evaluate and migrate toward RDI, with Debezium remaining in production until that evaluation is complete.

Implication: RDI’s replication semantics must be validated against Debezium’s before full cutover. This is a direct instance of the same principle behind the migration’s replication-validation layer, described next: verifying that a change downstream matches what was written upstream, not just that it arrived.


Preparing to Migrate Millions of Legacy Records

The architecture above solves synchronization for new data. Millions of legacy location records still sit in the old schema and need to move into the new one, without corrupting data or degrading the service that depends on it.

Running ad hoc update scripts against a production database subject to strict compliance requirements was never an option. Instead, I designed a standalone Java command-line migration utility built on Spring Batch:

---
config:
  look: neo
---
flowchart TD
    L["Legacy records"] --> R["Chunked reader<br/>1,000 records per commit"]
    R --> P["Processor<br/>transform + validate"]
    P --> W["Writer<br/>new schema"]
    P -- fails validation --> DL["Dead-letter table<br/>human review"]
    R -.-> MD["Batch metadata tables<br/>progress, throughput, errors"]

The tool processes legacy records in transaction-bounded chunks of 1,000, balancing database I/O throughput against memory footprint and transaction size. A record that fails validation is routed to a dead-letter table for human review instead of halting the entire run, and batch metadata tables expose step-level progress, throughput, and error metrics in real time.

This tool has been validated through repeated dry runs. The production migration itself has not started.

What dry runs have surfaced

Dry runs exposed a class of errors that row-count reconciliation alone could never detect. No production incident exists to report here, because the migration has not touched production; what dry runs did expose is worth documenting anyway. The migration logic assumed a one-to-one mapping between a legacy field and a field in the new schema, and that assumption did not hold for every table. Some legacy fields required a business rule to disambiguate correctly against the new schema. Record counts matched in every one of those cases; the values did not. That gap is exactly why the validation strategy below does not stop at counting rows.

Why the validation strategy matters more than the batch job

A batch job reaching 100% completion demonstrates almost nothing on its own. Moving rows is easy. Proving that the right rows moved, in the right shape, and that systems downstream of the migration behave correctly afterward, is hard. The validation plan has five layers:

# Validation layer What it catches
1 Source/destination record-count reconciliation Missing or duplicated rows between source and destination
2 Field-level validation Silent transformation bugs where a value changes incorrectly in type or content
3 Business-rule validation Domain constraints the compliance schema requires, which a raw diff would not catch
4 Replication validation Confirms migrated records propagate through CDC into Redis and that the Redis representation matches the expected location data
5 Post-migration sampling Targeted review of a representative sample, catching systematic errors the automated layers miss

Replication validation is the layer most worth calling out. It validates the entire downstream path, not the database migration in isolation. A record can migrate perfectly into the new schema and still be wrong if it does not show up correctly in the cache the location service actually reads from.

A successful migration is not demonstrated by a job reaching 100% completion. It is demonstrated by independent reconciliation of the resulting data and downstream behavior.


Production and Operations


Anti-Patterns


Key Takeaways

  1. Separate the write and read paths when their consistency and latency requirements conflict. Provisioning needs strict transactional consistency; location delivery needs to stay fast under load. Sharing infrastructure between the two means one workload’s spike degrades the other’s guarantees.

  2. Prefer log-based CDC over application-level dual writes when synchronizing a system of record with a serving cache. Dual writes create a partial-failure mode with no single source of truth for which write actually landed. CDC removes that failure mode by making the database the only write path and propagating changes out of band.

  3. Build the migration’s validation strategy before the migration itself. Completion percentage is not correctness. Independent reconciliation across record-count, field-level, business-rule, and downstream-replication layers is what actually proves the data can be trusted.

  4. Consolidate ownership when a team boundary becomes a coordination cost. Aligning API contracts and testing governance under one accountable owner removed the negotiation overhead between two teams and moved the delivery timeline forward instead of stalling it.


The migration is still ahead of us. When it runs in production, I will follow up with the results, particularly which validation layers caught issues that the others did not.

For related work, see Projects.