Real-Time Systems

Real-Time Dashboards: MVs, CDC, & Data Freshness

Unlock true real-time analytics for your dashboards. Learn to combine Materialized Views and Change Data Capture for unparalleled data freshness and performance.

Khader Vali July 31, 2026 9 min read

As a senior software engineer, I’ve seen countless organizations grapple with the challenge of delivering timely, accurate data to their stakeholders. In today’s fast-paced digital world, stale data is often useless data. Decision-makers need insights *now*, not hours or even minutes from now. This demand for immediate insights drives the need for real-time dashboards, which present a unique set of technical challenges.

Traditional dashboards often rely on batch processing, where data is extracted, transformed, and loaded (ETL) into a data warehouse at scheduled intervals. While effective for historical analysis, this approach introduces significant latency. For scenarios like monitoring live transactions, tracking system health, or reacting to customer behavior in real-time, batch processing simply doesn’t cut it. This is where the powerful combination of Materialized Views (MVs) and Change Data Capture (CDC) comes into play, offering a robust and efficient path to truly real-time data.

In this comprehensive guide, we’ll dive deep into the concepts of MVs and CDC, explore their individual strengths, and then demonstrate how their synergy can revolutionize your data pipelines for real-time dashboarding. We’ll cover architectural patterns, practical implementation steps, code examples, and discuss real-world scenarios where this approach shines.

The Perils of Stale Data: Why Real-Time Matters

Imagine an e-commerce platform where a dashboard displays current sales figures, inventory levels, or user activity. If these metrics are updated only once an hour, critical decisions could be based on outdated information. A marketing campaign might launch targeting out-of-stock items, or customer support might be unaware of recent purchase issues. In the realm of financial trading, even a few seconds of data lag can mean millions in losses.

The core problem with traditional dashboarding is the reliance on querying operational databases directly or through scheduled ETL jobs:

  • Direct Queries on Operational Databases: Hitting your primary transactional database for complex analytical queries can significantly impact its performance, slowing down critical user-facing applications. Operational databases are optimized for rapid, small transactions, not large analytical scans.
  • Batch ETL Processes: While offloading analytical queries, batch ETL introduces inherent latency. The data is only as fresh as the last completed batch job. For high-velocity data, this can lead to data being minutes or hours old.
  • Complex Joins and Aggregations: Real-time dashboards often require complex aggregations and joins across multiple tables. Performing these computations on the fly for every dashboard refresh is computationally expensive and slow, even on a dedicated analytical database.

We need a way to pre-compute and store aggregated, dashboard-ready data that is *always* up-to-date, without impacting our transactional systems. This is precisely the problem that Materialized Views and Change Data Capture, working in tandem, solve.

Real-Time Dashboards: MVs, CDC, & Data Freshness
Generated Image

Materialized Views: Your Pre-Computed Powerhouses

Think of a Materialized View (MV) as a pre-computed cache of a query result. Unlike a standard view, which is merely a stored query definition executed every time it’s referenced, an MV stores the *actual data* resulting from the query. This means when you query an MV, you’re reading directly from a table, not re-executing a potentially complex join or aggregation.

What Exactly is a Materialized View?

At its heart, an MV is a database object that contains the results of a query. This query can be as simple as selecting all columns from a table or as complex as joining multiple tables, filtering data, and performing aggregations (sums, counts, averages, etc.). The key differentiator is that the results are physically stored, just like a regular table. This storage allows for incredibly fast query performance because the heavy lifting of computation has already been done.

How Do Materialized Views Work?

When you create an MV, the database executes the defining query and stores its output. The challenge, then, is keeping this stored data fresh. MVs are not automatically updated when the underlying base tables change. They need to be “refreshed.” The refresh mechanism is crucial for real-time scenarios:

  • Full Refresh: The simplest method, where the entire MV is re-computed from scratch by re-running its defining query. This can be resource-intensive and slow for large MVs.
  • Fast/Incremental Refresh: Some database systems (like Oracle, and to a limited extent, PostgreSQL with specific extensions or manual logic) support incremental refreshes. This method only applies the changes (inserts, updates, deletes) that have occurred in the base tables since the last refresh, making it much faster. This is where CDC becomes invaluable.

Advantages of Materialized Views for Dashboards

  • Performance: Queries against MVs are significantly faster because the data is pre-aggregated and stored in an optimized format. This is critical for highly interactive dashboards.
  • Reduced Load on Source Systems: By querying the MV instead of the operational tables, you offload analytical workload from your primary transactional databases, preventing performance degradation for live applications.
  • Simplicity for Dashboard Tools: BI tools and dashboard applications can query MVs as if they were regular tables, simplifying dashboard development.
  • Complex Data Transformation: MVs can encapsulate complex SQL logic, making the dashboard layer cleaner and easier to maintain.

Limitations of Materialized Views (in isolation)

  • Data Staleness: Without a proper refresh strategy, MVs can become stale.
  • Refresh Cost: Full refreshes can be time-consuming and resource-intensive, especially for very large datasets, potentially negating the performance benefits if refreshes are too frequent.
  • Complexity of Incremental Refresh: Implementing incremental refreshes without specialized tools (like CDC) often requires complex manual logic, triggers, or specific database features.

Basic Materialized View Example (PostgreSQL)

Let’s say we have an orders table and a products table, and we want a dashboard showing daily sales totals by product category.


CREATE TABLE products (
    product_id SERIAL PRIMARY KEY,
    product_name VARCHAR(100),
    category VARCHAR(50)
);

CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    product_id INT REFERENCES products(product_id),
    order_date DATE,
    quantity INT,
    price_per_unit DECIMAL(10, 2)
);

-- Insert some sample data
INSERT INTO products (product_name, category) VALUES
('Laptop Pro', 'Electronics'),
('Mechanical Keyboard', 'Electronics'),
('Ergonomic Chair', 'Office'),
('Coffee Mug', 'Home Goods');

INSERT INTO orders (product_id, order_date, quantity, price_per_unit) VALUES
(1, '2023-10-26', 1, 1500.00),
(2, '2023-10-26', 2, 120.00),
(1, '2023-10-27', 1, 1500.00),
(3, '2023-10-27', 1, 400.00),
(4, '2023-10-28', 5, 15.00);

-- Create a Materialized View for daily sales by category
CREATE MATERIALIZED VIEW daily_sales_by_category AS
SELECT
    o.order_date,
    p.category,
    SUM(o.quantity * o.price_per_unit) AS total_sales,
    COUNT(DISTINCT o.order_id) AS distinct_orders
FROM
    orders o
JOIN
    products p ON o.product_id = p.product_id
GROUP BY
    o.order_date, p.category
ORDER BY
    o.order_date, p.category;

-- Query the MV (very fast!)
SELECT * FROM daily_sales_by_category;

-- To refresh the MV (requires a full recompute by default in PostgreSQL)
REFRESH MATERIALIZED VIEW daily_sales_by_category;

Without a mechanism to efficiently detect and apply changes, `REFRESH MATERIALIZED VIEW` can be a performance bottleneck if executed too frequently or on large datasets. This is where Change Data Capture steps in.

Change Data Capture (CDC): The Real-Time Data Stream

Change Data Capture (CDC) is a design pattern used to track and capture changes made to a database and deliver those changes in real-time to other systems. Instead of periodically querying the database for new or updated records (which can be inefficient and miss deletes), CDC focuses on identifying *what* data has changed, *when*, and *how*.

How Does CDC Work?

The most robust and common form of CDC works by tapping into the database’s transaction log (also known as the write-ahead log or WAL). Every modification (insert, update, delete) to a database is first recorded in this log before being applied to the actual data files. CDC tools read this log, interpret the changes, and then publish them as a stream of events.

Key Characteristics of Log-Based CDC:

  • Non-Intrusive: It doesn’t require modifying your application code or adding triggers to your operational database tables, minimizing impact on your primary application.
  • Low Latency: Changes are captured almost instantaneously as they are written to the transaction log.
  • High Fidelity: Provides the complete history of changes, including the old and new states of records for updates, and full records for inserts and deletes.
  • Reliability: Leverages the ACID properties of the database’s transaction log, ensuring that no change is missed.

Other CDC Approaches:

  • Trigger-Based CDC: Involves creating database triggers on tables to record changes into a separate shadow table. This is more intrusive, adds overhead to transactions, and can be complex to manage for schema changes.
  • Query-Based CDC (Timestamp/Version Columns): Periodically queries tables for records modified since the last check, typically using a `last_updated_at` timestamp or version number. This is simpler to implement but can miss deleted records and is less efficient for high-volume changes.

For real-time dashboards and robust data pipelines, log-based CDC is the gold standard.

Popular CDC Tools and Technologies

  • Debezium: An open-source distributed platform for CDC. It builds on Kafka Connect and provides connectors for various databases (PostgreSQL, MySQL, SQL Server, MongoDB, Oracle). Debezium captures changes and streams them into Apache Kafka.
  • Apache Kafka: While not a CDC tool itself, Kafka is the de-facto standard message broker for handling CDC events. Its durability, scalability, and ability to handle high throughput make it ideal for this purpose.
  • PostgreSQL Logical Decoding: PostgreSQL’s native capability to stream its transaction log. Debezium leverages this feature.
  • AWS DMS (Database Migration Service): A managed service that can perform CDC for various databases, often used for replicating data to other AWS services like S3 or Kinesis.
  • Confluent Platform: Commercial offering built on Kafka that includes connectors and tools for CDC.

Why CDC is Crucial for Real-Time

CDC provides the continuous, granular stream of changes needed to keep downstream systems, like Materialized Views, fresh with minimal latency. Instead of waiting for a batch job or performing a full re-computation, CDC tells us *exactly* what changed, allowing for surgical updates.

Real-Time Dashboards: MVs, CDC, & Data Freshness
Generated Image

The Synergy: MVs + CDC for Real-Time Dashboards

The true power emerges when Materialized Views and Change Data Capture are combined. CDC provides the real-time stream of granular changes from your source database, and MVs provide the pre-computed, aggregated data structure optimized for fast dashboard queries. The link between them is a processing layer that consumes CDC events and incrementally updates the MVs.

Architectural Overview (Diagram in Words)

Let’s visualize the flow of data in this architecture:


+-------------------+       +-------------------+       +-------------------+
|   Source Database |       |     CDC Tool      |       |    Message Queue  |
| (e.g., PostgreSQL)| <---> | (e.g., Debezium)  | ----> | (e.g., Kafka)     |
|   (Operational)   |       |                   |       |                   |
+-------------------+       +-------------------+       +-------------------+
        ^                                                           |
        | (Transaction Log/WAL)                                     | (Stream of Change Events)
        |                                                           |
        |                                                           v
+-------------------+       +-------------------+       +-------------------+
|  Dashboard/BI Tool| <---  | Materialized Views| <---- | Stream Processor  |
| (e.g., Grafana)   |       | (e.g., PostgreSQL |       | (e.g., Kafka      |
|                   |       |     MV Tables)    |       |  Streams, Flink)  |
+-------------------+       +-------------------+       +-------------------+

Explanation of Components:

  1. Source Database (Operational): This is your primary transactional database where all application writes occur. It must be configured for logical replication or a similar CDC-friendly mechanism.
  2. CDC Tool (e.g., Debezium): This component connects to the source database's transaction log. It continuously reads the log, captures row-level changes (inserts, updates, deletes), and converts them into structured events (e.g., JSON).
  3. Message Queue (e.g., Apache Kafka): The CDC tool publishes these change events as messages to a durable, distributed message queue. Kafka is ideal due to its high throughput, fault tolerance, and ability to store events for reprocessing. Each table's changes might go to a dedicated Kafka topic.
  4. Stream Processor (e.g., Kafka Streams, Apache Flink, Custom Consumer): This is the brain of the real-time update engine. It consumes the change events from the message queue. For each event, it determines how to update the relevant Materialized View. This often involves applying the changes incrementally to the MV.
  5. Materialized Views (Analytical Database/Tables): These are your pre-computed, dashboard-ready datasets. They reside in a database (which could be the same as the source, a separate analytical database, or even a specialized data store). The stream processor updates these MVs based on the CDC events.
  6. Dashboard/BI Tool: Your dashboard application (e.g., Grafana, Tableau, Power BI) connects directly to these Materialized Views. Because the MVs are continuously updated, the dashboards always reflect the freshest data with minimal query latency.

How it Addresses Real-Time Needs

  • Low Latency: Changes are propagated from the source database to the dashboard in seconds, often sub-second, because they flow continuously through the pipeline.
  • High Performance: Dashboards query highly optimized, pre-aggregated MVs, ensuring rapid response times even for complex analytics.
  • Minimal Impact: The operational database remains largely unaffected by analytical queries or the CDC process itself (log reading is usually very light on resources).
  • Scalability: Each component (CDC, Kafka, Stream Processor, MV database) can be scaled independently to handle increasing data volumes.
  • Durability and Reliability: Kafka provides durable storage for events, allowing for fault tolerance and reprocessing if the stream processor fails.

Building Blocks: Step-by-Step Implementation Guide

Let's walk through a practical implementation using PostgreSQL as the source database, Debezium for CDC, Apache Kafka as the message queue, and a Python-based consumer for updating MVs.

Step 1: Setting up the Source Database for CDC (PostgreSQL)

For Debezium to work with PostgreSQL, you need to enable logical decoding. This allows external tools to read the transaction log.

Database Configuration:

Edit your postgresql.conf file:


wal_level = logical
max_replication_slots = 10  # Or a suitable number for your use case
max_wal_senders = 10        # Or a suitable number

Restart PostgreSQL after making these changes.

Create a Replication User:

Debezium needs a user with replication privileges.


CREATE ROLE debezium_user WITH LOGIN PASSWORD 'your_secure_password';
GRANT rds_replication TO debezium_user; -- For AWS RDS
-- OR for self-managed PostgreSQL:
ALTER ROLE debezium_user REPLICATION;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium_user; -- Or specific tables

Step 2: Implementing CDC with Debezium and Kafka

We'll use Debezium deployed as a Kafka Connect connector.

Install Kafka and Kafka Connect:

Follow the official Apache Kafka documentation to set up a Kafka cluster and Kafka Connect. Kafka Connect is a framework for streaming data between Apache Kafka and other systems.

Deploy Debezium PostgreSQL Connector:

Download the Debezium PostgreSQL connector JAR files and place them in your Kafka Connect plugin path.

Create a Debezium Connector Configuration:

This JSON configuration tells Kafka Connect how to connect to PostgreSQL and what to capture. Save it as debezium-postgres-connector.json.


{
  "name": "sales-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "database.hostname": "your_postgres_host",
    "database.port": "5432",
    "database.user": "debezium_user",
    "database.password": "your_secure_password",
    "database.dbname": "your_database_name",
    "database.server.name": "sales_db_server",
    "schema.include.list": "public",
    "table.include.list": "public.orders,public.products",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_slot",
    "topic.prefix": "dbserver",
    "heartbeat.interval.ms": "5000"
  }
}

Submit the Connector:

Use the Kafka Connect REST API to deploy the connector.


curl -X POST -H "Content-Type: application/json" --data @debezium-postgres-connector.json http://localhost:8083/connectors

Once deployed, Debezium will start capturing changes from your orders and products tables and publishing them to Kafka topics named like dbserver.public.orders and dbserver.public.products.

Step 3: Processing CDC Events and Maintaining Materialized Views

This is where the real-time logic resides. You'll need a stream processing application that consumes messages from Kafka, processes them, and updates your Materialized Views. For simplicity, we'll use a custom Python consumer, but for production, consider Kafka Streams, Flink, or Spark Streaming.

Define your Materialized View (again, in the same or a separate database):

Let's assume this MV lives in the same PostgreSQL instance or a dedicated analytical PostgreSQL instance.


-- Drop if exists for clean slate, be careful in production!
DROP MATERIALIZED VIEW IF EXISTS daily_sales_by_category_realtime;

CREATE MATERIALIZED VIEW daily_sales_by_category_realtime AS
SELECT
    o.order_date,
    p.category,
    SUM(o.quantity * o.price_per_unit) AS total_sales,
    COUNT(DISTINCT o.order_id) AS distinct_orders
FROM
    orders o
JOIN
    products p ON o.product_id = p.product_id
GROUP BY
    o.order_date, p.category
WITH NO DATA; -- Initially empty, will be populated by consumer

Note the WITH NO DATA. We'll populate it incrementally.

Python Stream Processor (Conceptual Code):

This Python script will consume Kafka messages and update the MV. We need to handle `INSERT`, `UPDATE`, and `DELETE` operations for both `orders` and `products` tables. The complexity arises in applying these changes to the *aggregated* view. For complex aggregations, a full recomputation of the affected group might still be necessary, but only for the *changed rows*, not the entire MV.

For our daily_sales_by_category MV, a change in an order or product will impact a specific (order_date, category) combination. The strategy typically involves:

  1. When an order or product changes:
    • Identify the affected (order_date, category) group.
    • Re-calculate the aggregates for that specific group based on the latest data in the source tables (which the CDC events reflect).
    • Update or insert this group's aggregates in the daily_sales_by_category_realtime MV.

This requires the stream processor to either:

  1. Maintain a local cache of the MV data and update it.
  2. Execute targeted SQL updates/inserts/deletes against the MV table.
  3. Or, more robustly, reconstruct the necessary aggregate rows by querying the *source tables* (or a continuously updated replica of them) based on the event's primary key, and then upsert into the MV.

Let's consider a simplified approach for `orders` table changes. When an order changes, we get the `order_id`. We need to figure out its `order_date` and `category` (which requires looking up the `product_id` in the `products` table). Then, we re-calculate the sum for that specific `(order_date, category)` group.


from kafka import KafkaConsumer
import json
import psycopg2

# Database connection details for the MV
MV_DB_HOST = "your_mv_db_host"
MV_DB_NAME = "your_mv_db_name"
MV_DB_USER = "your_mv_db_user"
MV_DB_PASSWORD = "your_mv_db_password"

# Kafka configuration
KAFKA_BOOTSTRAP_SERVERS = 'localhost:9092'
ORDERS_TOPIC = 'dbserver.public.orders'
PRODUCTS_TOPIC = 'dbserver.public.products'
GROUP_ID = 'mv_updater_group'

def get_db_connection():
return psycopg2.connect(
host=MV_DB_HOST,
database=MV_DB_NAME,
user=MV_DB_USER,
password=MV_DB_PASSWORD
)

def update_daily_sales_mv(order_date, category, conn):
"""
Recalculates and updates the aggregate for a specific date and category.
This function would typically query the *source* tables for the latest data
for the specific (date, category) combination, or maintain a local state.
For simplicity here, we assume it can re-compute based on the event.
In a real system, you might need to query the source tables for all orders
of that date/category or maintain a KTable of orders/products in Kafka Streams.
"""
cursor = conn.cursor()
# This is a critical point: how to get the 'latest' data for aggregation.
# For a true incremental update, you'd need the *current* state of all
# orders and products related to this category/date.
# A common pattern is:
# 1. Store the raw CDC events in a temporary store (e.g., KTable in Kafka Streams)
# 2. Join the KTable for orders and products
# 3. Aggregate based on the join
# 4. Upsert into the MV.

# For this example, let's assume we can fetch the current aggregate from the MV
# and adjust it, or perform a targeted re-aggregation.
# The most robust way is to re-compute for the affected group.
# This example shows a placeholder for targeted re-computation logic.

# In a real system, this SQL would query the *original* orders and products tables
# (or a real-time replica derived from CDC) to get the latest state for the specific group.
# For demonstration, let's assume we have the information to calculate the new aggregate.

# Example of a targeted re-computation SQL for a specific date and category:
recompute_sql = """
WITH latest_data AS (
SELECT
o.order_date,
p.category,
SUM(o.quantity * o.price_per_unit) AS total_sales,
COUNT(DISTINCT o.order_id) AS distinct_orders
FROM
orders o
JOIN
products p ON o.product_id = p.product_id
WHERE
o.order_date = %s AND p.category = %s
GROUP BY
o.order_date, p.category
)
INSERT INTO daily_sales_by_category_realtime (order_date, category, total_sales, distinct_orders)
SELECT order_date, category, total_sales, distinct_orders
FROM latest_data
ON CONFLICT (order_date, category) DO UPDATE
SET
total_sales = EXCLUDED.total_sales,
distinct_orders = EXCLUDED.distinct_orders;
"""
# Note: The 'orders' and 'products' tables in the above SQL are conceptually
# the ones being kept up-to-date by *another* CDC consumer or are the source tables themselves.
# If the MV is in a separate analytical database, these 'orders' and 'products'
# would be replicated tables in that analytical database.

# For this example, we'll simulate the recomputation using the parameters:
# In a real world scenario, you might get the order_id from the CDC event,
# then query the *source* database for that order and its associated product,
# determine the date/category, and then trigger a re-computation for that specific (date, category) group.

# Simplified mock for demonstration:
# Assuming the event gives us enough info to determine the date and category impacted.
print(f"Recomputing for date: {order_date}, category: {category}")
# This is where the actual logic to re-calculate and upsert would go.
# For a true real-time MV, you'd perform an UPSERT (UPDATE or INSERT)
# in the MV table for the affected group.

# Example of an UPSERT (PostgreSQL 9.5+):
try:
# This assumes 'orders' and 'products' tables are available and fresh in the
# MV database. A separate CDC pipeline might be keeping them updated.
cursor.execute(recompute_sql, (order_date, category))
conn.commit()
print(f"Updated MV for {order_date}, {category}")
except Exception as e:
conn.rollback()
print(f"Error updating MV: {e}")

def process_message(message, conn):
try:
# Debezium messages often have 'payload' and 'op' (operation type)
payload = json.loads(message.value.decode('utf-8'))
op = payload.get('payload', {}).get('op') # 'c' for create, 'u' for update, 'd' for delete

if not op:
print(f"Skipping heartbeat or schema change message: {payload}")
return

# 'before' for old state, 'after' for new state
before = payload.get('payload', {}).get('before

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

WebTransport & WebRTC: Next-Gen Real-Time Web Communication

Jul 14, 2026 · 7 min read

CRDT vs OT: Real-time Data Synchronization Explained

Jul 09, 2026 · 6 min read

WebTransport & WebRTC: Next-Gen Real-Time Web Communication

Jul 04, 2026 · 6 min read