Hospital monitoring systems live and die by alert latency. A bedside monitor reports a heart rate of 132 with an oxygen saturation of 84 percent, and the question is how long until the on-call nurse's pager goes off. Many in-house systems still poll a database every 10 to 30 seconds, which is fast enough for billing but slow enough that a deteriorating ICU patient can pass a critical threshold and sit there silently for a full polling cycle.
This post walks through a streaming SQL alternative built on RisingWave. Vitals stream into a source, rolling averages and threshold-based alerts are materialized views, and alerts can be sinked to Kafka for downstream pager integration. The architecture is a few hundred lines of SQL. Every snippet below has been verified against RisingWave 2.8.0.
A note before we start. This article describes architectural patterns. It is not a HIPAA compliance guide, a medical device clearance plan, or clinical advice. Production deployments need a HIPAA-compliant deployment environment, a documented quality system, and clinical validation of any rule that triggers patient-facing action.
Why Streaming SQL Suits Patient Monitoring
Streaming SQL suits patient monitoring because the workload looks like incremental SQL aggregation, not like a custom polling daemon. Every reading from a bedside device should update a rolling average and a threshold rule the moment it lands. A streaming database keeps materialized views fresh per event, sub-second, with standard SQL as the rule language. There is no scheduler, no polling loop, and no separate ingestion pipeline to debug.
| Layer | Traditional polling system | Streaming SQL (RisingWave) |
|---|---|---|
| Ingest | App writes to OLTP DB | Source from Kafka, MQTT bridge, or table |
| Aggregation | Cron job every 10–30 sec | Materialized view, updated per event |
| Rule language | Custom code in Java/Python | Standard SQL |
| Alert latency | 10–60 seconds (polling cycle) | Sub-second |
| Add a new rule | New code, new deploy | CREATE MATERIALIZED VIEW |
| State management | External cache or query | Built into the engine |
The rule language matters as much as the latency. SQL is auditable. Clinical engineers can read a CREATE MATERIALIZED VIEW and reason about exactly which thresholds fire. A pile of Python with retry logic, polling intervals, and ad-hoc state caches is harder to review.
The Data Model
A workable patient vitals stream is one row per device reading, tagged with the patient and the bed. Bedside monitors typically emit at one to several samples per second per metric. For verification, we use a regular table seeded with three patients. In production this is a CREATE SOURCE over Kafka or MQTT bridged to Kafka, with the same columns.
CREATE TABLE patient_vitals (
patient_id VARCHAR,
bed_id VARCHAR,
heart_rate INT,
spo2 INT,
systolic_bp INT,
diastolic_bp INT,
resp_rate INT,
temp_c DOUBLE PRECISION,
event_time TIMESTAMPTZ
);
Seed three patients with realistic readings. Patient p001 is deteriorating over the last 5 minutes. Patient p002 is stable. Patient p003 has a tachycardia spike.
INSERT INTO patient_vitals VALUES
('p001','ICU-3A', 82, 97, 122, 78, 16, 36.8, NOW() - INTERVAL '5 minutes'),
('p001','ICU-3A', 84, 97, 120, 76, 16, 36.9, NOW() - INTERVAL '4 minutes 30 seconds'),
('p001','ICU-3A', 88, 96, 118, 74, 17, 37.0, NOW() - INTERVAL '4 minutes'),
('p001','ICU-3A', 95, 95, 115, 72, 18, 37.1, NOW() - INTERVAL '3 minutes 30 seconds'),
('p001','ICU-3A', 108, 92, 108, 68, 22, 37.4, NOW() - INTERVAL '3 minutes'),
('p001','ICU-3A', 115, 90, 102, 64, 24, 37.6, NOW() - INTERVAL '2 minutes 30 seconds'),
('p001','ICU-3A', 122, 88, 98, 60, 26, 37.8, NOW() - INTERVAL '2 minutes'),
('p001','ICU-3A', 128, 86, 92, 58, 28, 38.0, NOW() - INTERVAL '90 seconds'),
('p001','ICU-3A', 132, 84, 88, 56, 30, 38.1, NOW() - INTERVAL '60 seconds'),
('p001','ICU-3A', 136, 82, 86, 54, 30, 38.2, NOW() - INTERVAL '30 seconds'),
('p002','ICU-3B', 72, 98, 118, 76, 15, 36.7, NOW() - INTERVAL '5 minutes'),
('p002','ICU-3B', 74, 98, 119, 77, 15, 36.7, NOW() - INTERVAL '4 minutes'),
('p002','ICU-3B', 73, 99, 120, 78, 15, 36.8, NOW() - INTERVAL '3 minutes'),
('p002','ICU-3B', 75, 98, 121, 78, 16, 36.8, NOW() - INTERVAL '2 minutes'),
('p002','ICU-3B', 72, 98, 119, 77, 15, 36.7, NOW() - INTERVAL '1 minute'),
('p002','ICU-3B', 73, 99, 120, 78, 15, 36.7, NOW() - INTERVAL '30 seconds'),
('p003','ICU-4A', 110, 95, 130, 82, 20, 37.2, NOW() - INTERVAL '2 minutes'),
('p003','ICU-4A', 118, 95, 128, 80, 22, 37.3, NOW() - INTERVAL '90 seconds'),
('p003','ICU-4A', 125, 94, 126, 78, 24, 37.3, NOW() - INTERVAL '60 seconds'),
('p003','ICU-4A', 124, 95, 128, 80, 23, 37.2, NOW() - INTERVAL '30 seconds');
Materialized View 1: Rolling 60-Second Averages
Rolling 60-second averages smooth out noisy device samples and give clinicians a trend rather than a flicker. Each window emits a row containing the average heart rate, oxygen saturation, blood pressure, respiration rate, and temperature over the prior minute. RisingWave's HOP windowing function produces overlapping windows on a sliding interval, which is the right tool for a continuously refreshed bedside display.
CREATE MATERIALIZED VIEW patient_vitals_rolling AS
SELECT
patient_id,
bed_id,
window_start,
window_end,
ROUND(AVG(heart_rate)::numeric, 1) AS avg_hr,
ROUND(AVG(spo2)::numeric, 1) AS avg_spo2,
ROUND(AVG(systolic_bp)::numeric, 1) AS avg_sbp,
ROUND(AVG(diastolic_bp)::numeric, 1) AS avg_dbp,
ROUND(AVG(resp_rate)::numeric, 1) AS avg_rr,
ROUND(AVG(temp_c)::numeric, 2) AS avg_temp_c,
COUNT(*) AS sample_count
FROM HOP(patient_vitals, event_time,
INTERVAL '15 seconds', INTERVAL '60 seconds')
GROUP BY patient_id, bed_id, window_start, window_end;
The HOP(events, time, slide, size) syntax says: produce a 60-second window that slides forward every 15 seconds. So a bedside display refreshes four times per minute, each refresh showing the prior minute's average.
Query the most recent windows for the deteriorating patient:
SELECT patient_id, avg_hr, avg_spo2, avg_sbp, sample_count, window_end
FROM patient_vitals_rolling
WHERE patient_id = 'p001'
ORDER BY window_end DESC
LIMIT 5;
Output:
patient_id | avg_hr | avg_spo2 | avg_sbp | sample_count | window_end
------------+--------+----------+---------+--------------+---------------------------
p001 | 136 | 82 | 86 | 1 | 2026-05-22 21:17:30+00:00
p001 | 136 | 82 | 86 | 1 | 2026-05-22 21:17:15+00:00
p001 | 134 | 83 | 87 | 2 | 2026-05-22 21:17:00+00:00
p001 | 134 | 83 | 87 | 2 | 2026-05-22 21:16:45+00:00
p001 | 130 | 85 | 90 | 2 | 2026-05-22 21:16:30+00:00
The trend is unambiguous: heart rate climbing, SpO2 falling, systolic BP dropping. Exactly the picture you want a clinician to see.
For more on windowing semantics in RisingWave, see the streaming SQL window functions guide.
Materialized View 2: Per-Reading Threshold Alerts
A per-reading alert view fires as soon as any one device sample crosses a threshold, without waiting for a window to close. This is the lowest-latency rule layer. The view tags each violating reading with a severity and a structured list of alert codes, so the downstream router can decide what to do with each one.
CREATE MATERIALIZED VIEW patient_vitals_alerts AS
SELECT
patient_id,
bed_id,
heart_rate,
spo2,
systolic_bp,
resp_rate,
event_time,
CASE
WHEN heart_rate > 130 OR spo2 < 88 OR systolic_bp < 90 THEN 'CRITICAL'
WHEN heart_rate > 110 OR spo2 < 92 OR systolic_bp < 100 THEN 'WARNING'
ELSE 'INFO'
END AS severity,
ARRAY_REMOVE(ARRAY[
CASE WHEN heart_rate > 110 THEN 'TACHYCARDIA' END,
CASE WHEN spo2 < 92 THEN 'HYPOXEMIA' END,
CASE WHEN systolic_bp < 100 THEN 'HYPOTENSION' END,
CASE WHEN resp_rate > 24 THEN 'TACHYPNEA' END
], NULL) AS alert_codes
FROM patient_vitals
WHERE heart_rate > 110
OR spo2 < 92
OR systolic_bp < 100
OR resp_rate > 24;
The WHERE clause acts as a pre-filter so the view only contains rows that breach at least one threshold. The CASE expression then classifies severity and the array builds the structured list of conditions for downstream routing.
Query:
SELECT patient_id, heart_rate, spo2, systolic_bp, severity, alert_codes, event_time
FROM patient_vitals_alerts
ORDER BY event_time DESC
LIMIT 5;
Output:
patient_id | heart_rate | spo2 | systolic_bp | severity | alert_codes | event_time
------------+------------+------+-------------+----------+-----------------------------------------------+-------------------------------
p001 | 136 | 82 | 86 | CRITICAL | {TACHYCARDIA,HYPOXEMIA,HYPOTENSION,TACHYPNEA} | 2026-05-22 21:16:43.372+00:00
p003 | 124 | 95 | 128 | WARNING | {TACHYCARDIA} | 2026-05-22 21:16:43.372+00:00
p001 | 132 | 84 | 88 | CRITICAL | {TACHYCARDIA,HYPOXEMIA,HYPOTENSION,TACHYPNEA} | 2026-05-22 21:16:13.372+00:00
p003 | 125 | 94 | 126 | WARNING | {TACHYCARDIA} | 2026-05-22 21:16:13.372+00:00
p003 | 118 | 95 | 128 | WARNING | {TACHYCARDIA} | 2026-05-22 21:15:43.372+00:00
p001 is firing CRITICAL with four concurrent alert codes. p003 is firing WARNING with a single TACHYCARDIA code. p002, the stable patient, has no rows in the view at all because none of its readings violate any threshold.
Thresholds in this example are placeholders. Real clinical thresholds depend on patient age, baseline, sedation state, and care setting. Calibrate against the protocols your clinical team owns. The streaming layer's job is to make whatever thresholds you choose evaluate per-event with low latency.
Materialized View 3: Trend-Based Deterioration Score
A trend-based score catches the patient who is sliding toward critical without yet having any single reading that crosses a hard line. The pattern is a layered materialized view: aggregate the rolling averages from view 1, then compute a simple early warning score on top of them.
CREATE MATERIALIZED VIEW patient_deterioration AS
SELECT
patient_id,
bed_id,
window_end,
avg_hr,
avg_spo2,
avg_sbp,
avg_rr,
(CASE WHEN avg_hr > 110 THEN 1 ELSE 0 END)
+ (CASE WHEN avg_spo2 < 92 THEN 1 ELSE 0 END)
+ (CASE WHEN avg_sbp < 100 THEN 1 ELSE 0 END)
+ (CASE WHEN avg_rr > 22 THEN 1 ELSE 0 END) AS ews_score
FROM patient_vitals_rolling;
Query patients with a score of 2 or more:
SELECT patient_id, avg_hr, avg_spo2, avg_sbp, avg_rr, ews_score, window_end
FROM patient_deterioration
WHERE ews_score >= 2
ORDER BY window_end DESC, patient_id
LIMIT 5;
Output:
patient_id | avg_hr | avg_spo2 | avg_sbp | avg_rr | ews_score | window_end
------------+--------+----------+---------+--------+-----------+---------------------------
p001 | 136 | 82 | 86 | 30 | 4 | 2026-05-22 21:17:30+00:00
p003 | 124 | 95 | 128 | 23 | 2 | 2026-05-22 21:17:30+00:00
p001 | 136 | 82 | 86 | 30 | 4 | 2026-05-22 21:17:15+00:00
p003 | 124 | 95 | 128 | 23 | 2 | 2026-05-22 21:17:15+00:00
p001 | 134 | 83 | 87 | 30 | 4 | 2026-05-22 21:17:00+00:00
p001 has all four signals abnormal; p003 has tachycardia and tachypnea on average. The view layers cleanly: when a new vitals row arrives, patient_vitals_rolling updates, and that update propagates into patient_deterioration. RisingWave handles the dependency graph automatically.
This is a simplified illustration, not a clinical Modified Early Warning Score implementation. The point is that any composite scoring rule your clinical team uses can be expressed as a layered view.
Sinking Alerts to Kafka
Per-reading alerts are most useful when they leave the database and reach a pager, EHR, or rapid response system. Sink the alert view directly to Kafka:
CREATE SINK patient_alerts_sink FROM patient_vitals_alerts
WITH (
connector = 'kafka',
properties.bootstrap.server = 'kafka:9092',
topic = 'alerts.patient_vitals'
) FORMAT PLAIN ENCODE JSON (force_append_only = 'true');
Each row in the materialized view becomes a Kafka message. A downstream microservice consumes the topic, applies routing (severity to channel, bed to nurse), suppresses duplicates with a deduplication window, and dispatches. Keeping the routing logic out of SQL lets the clinical engineering team change paging policy without touching the database.
For higher-trust delivery paths, consider deduplicating in SQL with a windowed alert (see the threshold alerts patterns guide) so the consumer never sees a storm of identical messages.
Architecture and Operational Considerations
A workable deployment puts RisingWave behind the device data path, not in it. The bedside device should still write to its primary store; the streaming pipeline subscribes to a change feed (Kafka, MQTT bridged into Kafka, or CDC from the OLTP store). That way RisingWave is a derived layer. A pipeline outage cannot lose a reading; it can only delay an alert.
A few practical points:
- Bounded retention on alerts: alert views are append-only by nature. Add a downstream consumer to archive or rotate them; the materialized view itself should be sized for active patients only.
- Late-arriving readings: device fleets in real hospitals reorder events. Use a watermark on the source, for example
WATERMARK FOR event_time AS event_time - INTERVAL '5 seconds', to let the windowing function tolerate small reorderings without dropping data. - Patient privacy: vitals data is PHI under HIPAA. The same encryption, access control, and audit-logging requirements that apply to the EHR apply here. RisingWave supports TLS and role-based access; integrate with your existing identity provider. Compliance is a deployment-level concern, not a feature of any single product.
- No medical device claim: a streaming database is not an FDA-cleared monitoring device. Use this layer for analytics, decision support, and operational dashboards. Patient-facing alerts that drive clinical action need to go through whatever cleared device or software-as-a-medical-device path your organization uses.
Key Takeaways
A real-time patient monitoring pipeline does not require a custom stream processor. Three layered materialized views give you:
- Rolling 60-second averages for bedside trend displays
- Per-reading threshold alerts with sub-second latency
- Trend-based deterioration scores built on top of the rolling view
The rule language is standard SQL. The wire protocol is Postgres, so existing BI tools connect without modification. Adding a new alert rule is a CREATE MATERIALIZED VIEW, not a deployment.
The streaming database is an analytics layer. It works alongside, not in place of, the cleared medical devices that govern direct patient care. Used that way, it replaces a lot of custom polling code with a small amount of auditable SQL.
To run the SQL above yourself, install RisingWave from the quickstart docs and execute the snippets in order. The dataset and views are self-contained.

