Skip to content
Guide · Time-series data engineering

Late-Arriving and Out-of-Order Sensor Events

Sensor readings do not always arrive in the order they were measured. A gateway that lost its uplink flushes an hour of buffered values, a Kafka consumer replays from an earlier offset, a meter data file lands the next morning. This page defines late and out-of-order data, shows a schema that records both event time and ingest time, measures lateness and out-of-order rows with SQL, applies a grace-period policy, keeps roll-ups correct by recomputing a bounded window of recent buckets on every run, and explains what late data costs in PostgreSQL, TimescaleDB, QuestDB and InfluxDB. The PostgreSQL and MySQL examples were run.

Facts checked 27 October 2026 against the vendors' official documentation. Versions covered: PostgreSQL 18 (run on 18.6 in PGlite 0.5.11); MySQL 8.0 (run on 8.0.46); Kafka Streams, QuestDB, InfluxDB OSS v2 and TimescaleDB documentation as published on 27 October 2026. Next review due April 2027. Products, licences and prices in this field change quickly; confirm on the vendor's site before you choose. No benchmarks are quoted unless the source and test setup are named.
Short answer
  • Store two timestamps: event time (when the sensor measured) as the series key, and ingest time (when the database received it). Lateness is the difference, and you cannot measure or manage it without both.
  • A reading is out of order when its event time is earlier than one already received for the same series; it is late when it arrives after the window it belongs to has already been processed. The two usually coincide, and both are normal in IoT pipelines.
  • Decide a grace period per data type. Rows inside it are inserted and picked up by the next roll-up run; rows beyond it go to a quarantine table for review rather than silently changing closed reports.
  • Recompute a bounded window of recent buckets from committed data on every run, sized from the grace period, and reconcile older periods on a slower schedule. In the run on this page, a 30-minute backfill committed after one run was included by the next, restoring 15 samples per bucket. A dirty-bucket list is shown only as an educational example, because it is not safe with concurrent ingestion as shown.
  • According to the documentation of the engines covered here, late rows cost more than in-order rows: they reduce physical time order (BRIN), land in older partitions, force QuestDB to rewrite parts of partitions, and make InfluxDB un-compact cold shards.
How we know: Research-based from the Apache Kafka Streams core concepts, the QuestDB documentation (out-of-order data), the InfluxDB OSS v2 documentation (shards and shard groups), the Tiger Data TimescaleDB documentation (continuous aggregates, hypercore), the PostgreSQL 18 documentation (WITH queries, INSERT, BRIN indexes, pg_stats) and the MySQL 8.0 reference manual (INSERT ... ON DUPLICATE KEY UPDATE, transaction isolation levels, InnoDB locking), plus the PostgreSQL 18 transaction isolation and explicit locking pages, checked 27 October 2026. The PostgreSQL statements were run on PostgreSQL 18.6 (PGlite 0.5.11) with the session time zone set to UTC, and the MySQL statements on MySQL 8.0.46, each in a single session; outputs are from those runs. Behaviour under concurrent ingestion was not verified, because PGlite is single-session: the recommended window recompute relies on documented READ COMMITTED snapshot behaviour and ON CONFLICT DO UPDATE, while the dirty-bucket refresh is labelled an educational example and its races and table-lock options are analysed from the documentation. The grace periods, window and margin are illustrative assumptions; the pg_stats correlation figures are an illustrative measurement on a small PGlite sample. Engine behaviour for QuestDB, InfluxDB, TimescaleDB and Kafka Streams is described from their documentation and was not run here.

Scope and definitions

This page is for engineers who own an ingestion pipeline for building, energy or industrial sensor data and need roll-ups, alarms and reports to stay correct when data arrives late. It covers the timing problem only. Removing duplicate deliveries is covered in handling duplicate telemetry events, and how roll-ups are refreshed in general in continuous aggregates vs materialized views.

The terms are used loosely across products, so these are this page's working definitions; where a vendor defines a term, the vendor's wording is noted.

  • Event time: when the measurement happened at the source. Kafka Streams' documentation uses the same term, with a GPS sensor as its example.
  • Ingest time (Kafka Streams also distinguishes processing time): when the pipeline or database received the record.
  • Out of order: the event time is earlier than one already received for the same series. QuestDB defines it against the latest timestamp already committed to the table.
  • Late: the record arrives after the window it belongs to has been aggregated, reported or closed. The two usually coincide, but not always: a reading a few seconds out of order can still arrive before its window is processed, and a point with no other recent readings can be late without being out of order.

Common causes in building and IoT systems: gateways that store and forward after a network outage, MQTT clients retrying after reconnecting, Kafka consumers replaying from an earlier offset, device clocks that drift, data loggers and utility meter files uploaded in batches, and manual corrections. QuestDB's documentation lists most of these, including sensors that buffer readings and flush them on reconnect.

Data model: record event time and ingest time

Key the readings table on event time, because queries, roll-ups and alarms are about when things happened. Add received_at as ingest time. Alongside the roll-up table, late_rejects holds rows that arrived beyond the grace period, and rollup_dirty lists buckets that need recalculating; rollup_dirty is used only by the educational dirty-bucket example further down, and the recommended window recompute does not need it.

Tables and two hours of on-time data with a 30-minute gap (run on PostgreSQL 18.6)
SET TIME ZONE 'UTC';

CREATE TABLE reading (
    point_id     integer NOT NULL,
    ts           timestamptz NOT NULL,          -- event time: when the sensor measured
    value        double precision,
    received_at  timestamptz NOT NULL,          -- ingest time: when the database got it
    PRIMARY KEY (point_id, ts)
);

CREATE TABLE reading_15m (
    point_id   integer NOT NULL,
    bucket     timestamptz NOT NULL,
    avg_value  double precision,
    samples    integer NOT NULL,
    PRIMARY KEY (point_id, bucket)
);

CREATE TABLE rollup_dirty (
    point_id  integer NOT NULL,
    bucket    timestamptz NOT NULL,
    PRIMARY KEY (point_id, bucket)
);

CREATE TABLE late_rejects (
    point_id     integer NOT NULL,
    ts           timestamptz NOT NULL,
    value        double precision,
    received_at  timestamptz NOT NULL,
    reason       text NOT NULL
);

-- On-time data: points 1-3, one reading a minute 08:00-09:59, received 5 s after measurement,
-- except point 1 from 08:20 to 08:49 (its gateway lost the uplink)
INSERT INTO reading (point_id, ts, value, received_at)
SELECT p, g.ts, 20 + p + extract(minute FROM g.ts) / 100, g.ts + interval '5 seconds'
FROM generate_series(1, 3) AS p
CROSS JOIN generate_series(timestamptz '2026-10-27 08:00+00', timestamptz '2026-10-27 09:59+00', interval '1 minute') AS g(ts)
WHERE NOT (p = 1 AND g.ts >= '2026-10-27 08:20+00' AND g.ts < '2026-10-27 08:50+00')
ORDER BY g.ts, p;

-- Roll-up as of 10:00
INSERT INTO reading_15m
SELECT point_id, date_bin(interval '15 minutes', ts, timestamptz '2000-01-01 00:00+00'), avg(value), count(*)
FROM reading GROUP BY 1, 2;

SELECT bucket::text, samples FROM reading_15m
WHERE point_id = 1 AND bucket >= '2026-10-27 08:15+00' AND bucket < '2026-10-27 09:00+00' ORDER BY bucket;
Output: the 08:15 and 08:45 buckets are short, 08:30 is missing
         bucket         | samples
------------------------+---------
 2026-10-27 08:15:00+00 |       5
 2026-10-27 08:45:00+00 |      10

Apply a grace period on ingest

At 10:05 the gateway reconnects and flushes its 30 buffered readings for point 1, and a replay also delivers a reading for point 2 measured at 05:10. The ingestion statement below applies a grace period in one statement; the 4 hours used here is an illustrative assumption for the example, not a recommended value (the factors for choosing yours follow the table): rows older than that go to late_rejects, the rest are inserted (duplicates ignored), and the buckets of the rows actually inserted are recorded in rollup_dirty (for the educational example only; with the recommended window recompute, drop the last step). It relies on two documented PostgreSQL behaviours: data-modifying statements in WITH always run to completion, and RETURNING after ON CONFLICT DO NOTHING returns only the rows that were inserted.

Grace-period ingest that marks dirty buckets (run on 18.6)
WITH incoming (point_id, ts, value, received_at) AS (
    SELECT 1, g.ts, 21 + extract(minute FROM g.ts) / 100, timestamptz '2026-10-27 10:05+00'
    FROM generate_series(timestamptz '2026-10-27 08:20+00', timestamptz '2026-10-27 08:49+00', interval '1 minute') AS g(ts)
    UNION ALL
    SELECT 2, timestamptz '2026-10-27 05:10+00', 22.1, timestamptz '2026-10-27 10:05+00'
),
rejected AS (
    INSERT INTO late_rejects
    SELECT point_id, ts, value, received_at, 'older than 4 h grace period'
    FROM incoming WHERE received_at - ts > interval '4 hours'
    RETURNING 1
),
inserted AS (
    INSERT INTO reading
    SELECT * FROM incoming WHERE received_at - ts <= interval '4 hours'
    ON CONFLICT (point_id, ts) DO NOTHING
    RETURNING point_id, ts
)
INSERT INTO rollup_dirty
SELECT DISTINCT point_id, date_bin(interval '15 minutes', ts, timestamptz '2000-01-01 00:00+00')
FROM inserted
ON CONFLICT DO NOTHING;

SELECT point_id, bucket::text FROM rollup_dirty ORDER BY 1, 2;
SELECT point_id, ts::text, (received_at - ts)::text AS lateness, reason FROM late_rejects;
Output
 point_id |         bucket
----------+------------------------
        1 | 2026-10-27 08:15:00+00
        1 | 2026-10-27 08:30:00+00
        1 | 2026-10-27 08:45:00+00

 point_id |           ts           | lateness |           reason
----------+------------------------+----------+-----------------------------
        2 | 2026-10-27 05:10:00+00 | 04:55:00 | older than 4 h grace period
Illustrative grace periods by data type (assumptions for the example, not recommendations; decide yours with the people who use the reports)
DataIllustrative grace periodBeyond the grace period
BMS trend points for dashboardsA few hours to a dayQuarantine; reload on request
Energy meter data used for billing or carbon reportingUntil the reporting period closes, often daysAccept as a restatement with an audit record
Alarms and eventsNo grace period: accept late eventsStore with both timestamps; do not drop
Device health (battery, signal)MinutesDrop or quarantine; only the latest value matters

Choosing your own grace period

No official source gives a universal value. Base yours on: the lateness you actually observe (the p99 and maximum from the queries in the next section, measured over weeks that include outages); how long gateways can buffer and how long the longest plausible outage lasts; when reports are sent or closed and whether they may be restated; whether the data feeds billing, carbon or contractual figures; how long raw data is kept (a grace period cannot be longer than raw retention); and what reprocessing a late window costs on your system.

Why quarantine instead of dropping

A reading beyond the grace period is usually still correct data. Dropping it hides a pipeline problem; inserting it silently changes reports that may already have been sent. A quarantine table makes the decision visible, and a periodic job can load approved rows and, once that load has committed, recompute the roll-up over their time range. Stream processors make the same trade-off: Kafka Streams discards a record that arrives after a window's grace period has passed.

Measure lateness and out-of-order rows

With both timestamps stored, lateness and out-of-order rates are ordinary queries. Track them per gateway or point group; a change from your normal baseline is the signal to investigate.

Lateness per point, and rows that arrived behind a later reading (run on 18.6)
SELECT point_id,
       count(*) AS readings,
       count(*) FILTER (WHERE received_at - ts > interval '1 minute') AS late_rows,
       max(received_at - ts)::text AS max_lateness,
       (percentile_cont(0.99) WITHIN GROUP (ORDER BY extract(epoch FROM received_at - ts)))::int AS p99_lateness_s
FROM reading GROUP BY point_id ORDER BY point_id;

SELECT point_id, count(*) AS out_of_order_rows
FROM (
    SELECT point_id, ts,
           max(ts) OVER (PARTITION BY point_id ORDER BY received_at, ts
                         ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS max_ts_before
    FROM reading
) s
WHERE ts < max_ts_before
GROUP BY point_id ORDER BY point_id;
Output
 point_id | readings | late_rows | max_lateness | p99_lateness_s
----------+----------+-----------+--------------+----------------
        1 |      120 |        30 | 01:45:00     |           6229
        2 |      120 |         0 | 00:00:05     |              5
        3 |      120 |         0 | 00:00:05     |              5

 point_id | out_of_order_rows
----------+-------------------
        1 |                30

Order by ingest time, then event time

The out-of-order query walks each point's rows in arrival order and compares each event time with the latest one seen before it. Rows that arrive in one batch share a received_at, so event time breaks the tie. If your ingestion service writes batches with a single received_at, this is exactly the behaviour you want; if it stamps rows individually, the result is the same.

Keep roll-ups correct: recompute a recent window

The recommended job does not track which buckets changed. On every run it recomputes every 15-minute bucket inside a bounded recent window from the raw rows committed at that moment, with an idempotent upsert, and a slower reconciliation run does the same for older closed periods. Its correctness rests only on documented PostgreSQL behaviour: under the default READ COMMITTED isolation a query sees only data committed before it began, so a late batch committed after one run is seen by every later run; and INSERT ... ON CONFLICT DO UPDATE gives an atomic insert-or-update outcome even under concurrency. A late row is therefore included by the first run after its commit, as long as its bucket is still inside the window.

Size the window from the grace period, because rows older than the grace period never reach reading: window = grace period + a margin that covers the longest ingest transaction (a row is visible only when it commits) + the interval between runs. Here that is 4 hours plus an illustrative 1-hour margin. The script below, which continues from the tables and on-time data of the first script, was run in a single session on PostgreSQL 18.6 (PGlite); concurrent sessions cannot be run here, so the timeline is simulated with explicit run times.

Window recompute: a late batch committed after one run is included by the next (run on 18.6, single session)
SET TIME ZONE 'UTC';
-- reading and reading_15m as defined above, with the same on-time data loaded

-- Recompute every 15-minute bucket from p_from (rounded down) onwards from committed rows
CREATE PROCEDURE recompute_reading_15m(p_from timestamptz, p_to timestamptz DEFAULT 'infinity')
LANGUAGE sql AS $$
  INSERT INTO reading_15m (point_id, bucket, avg_value, samples)
  SELECT point_id, date_bin(interval '15 minutes', ts, timestamptz '2000-01-01 00:00+00'),
         avg(value), count(*)
  FROM reading
  WHERE ts >= date_bin(interval '15 minutes', p_from, timestamptz '2000-01-01 00:00+00')
    AND ts < p_to
  GROUP BY 1, 2
  ON CONFLICT (point_id, bucket) DO UPDATE
  SET avg_value = EXCLUDED.avg_value, samples = EXCLUDED.samples;
  -- buckets in the range whose raw rows have all been deleted
  DELETE FROM reading_15m r
  WHERE r.bucket >= date_bin(interval '15 minutes', p_from, timestamptz '2000-01-01 00:00+00')
    AND r.bucket < p_to
    AND NOT EXISTS (SELECT 1 FROM reading x
                    WHERE x.point_id = r.point_id
                      AND x.ts >= r.bucket AND x.ts < r.bucket + interval '15 minutes');
$$;

-- Run at 10:00. Illustrative window: the 4 h grace period plus a 1 h margin
-- (in a real job: CALL recompute_reading_15m(now() - interval '5 hours');)
CALL recompute_reading_15m(timestamptz '2026-10-27 10:00+00' - interval '5 hours');
SELECT bucket::text, samples FROM reading_15m
WHERE point_id = 1 AND bucket >= '2026-10-27 08:15+00' AND bucket < '2026-10-27 09:00+00' ORDER BY bucket;

-- 10:05: the gateway's buffered readings commit after that run (grace-period filter as above)
INSERT INTO reading
SELECT 1, g.ts, 21 + extract(minute FROM g.ts) / 100, timestamptz '2026-10-27 10:05+00'
FROM generate_series(timestamptz '2026-10-27 08:20+00', timestamptz '2026-10-27 08:49+00', interval '1 minute') AS g(ts)
WHERE timestamptz '2026-10-27 10:05+00' - g.ts <= interval '4 hours'
ON CONFLICT (point_id, ts) DO NOTHING;

-- Next run at 10:15: the 08:15 to 08:45 buckets are still inside the window
CALL recompute_reading_15m(timestamptz '2026-10-27 10:15+00' - interval '5 hours');
SELECT bucket::text, samples FROM reading_15m
WHERE point_id = 1 AND bucket >= '2026-10-27 08:15+00' AND bucket < '2026-10-27 09:00+00' ORDER BY bucket;
SELECT count(*) AS buckets, sum(samples) AS samples FROM reading_15m;
Output
-- after the 10:00 run
         bucket         | samples
------------------------+---------
 2026-10-27 08:15:00+00 |       5
 2026-10-27 08:45:00+00 |      10

-- after the 10:15 run
         bucket         | samples
------------------------+---------
 2026-10-27 08:15:00+00 |      15
 2026-10-27 08:30:00+00 |      15
 2026-10-27 08:45:00+00 |      15

 buckets | samples
---------+---------
      24 |     360

Limits of the window recompute

  • Rows later than the window are not picked up by the regular runs. Quarantined rows released by an operator are the usual case: after the release transaction has committed, run CALL recompute_reading_15m(...) over the released range, and schedule a reconciliation over recently closed periods (for example the previous day) as a backstop. Keep reconciliation clear of raw data removed by retention, because the DELETE step removes roll-up rows whose raw rows are gone.
  • Cost grows with the window length times the data rate, not with the number of late rows, so a long grace period makes every run more expensive. That is the trade against the dirty-bucket design below, which is cheaper per run but not safe with concurrent ingestion as shown.
  • Overlapping runs do not break correctness, but waste work, and the run that commits last writes its own snapshot's figures until the next run; let the scheduler run one at a time.
  • Freshness: figures are as of the last run.

Other documented routes: a materialized view refreshed with REFRESH MATERIALIZED VIEW CONCURRENTLY recomputes everything from committed data, and TimescaleDB continuous aggregates handle invalidation internally (see continuous aggregates vs materialized views).

Educational example: refresh only the dirty buckets

Educational example: shows the idea in a single session; not safe for production as shown, because of the race described below. This job joins the dirty list from the grace-period ingest to the raw readings, upserts the recalculated buckets and clears the list, in one transaction so that a failure leaves the list intact. Its cost depends on the number of dirty buckets, not on the size of the table. The table lock in the second line is one of the documented options discussed below, not verified here; the run was a single session.

Educational example, not production-safe as shown: refresh the dirty buckets (run on 18.6, single session, continuing from the grace-period ingest above)
BEGIN;
LOCK TABLE reading IN SHARE ROW EXCLUSIVE MODE;  -- see the concurrency caveat below
INSERT INTO reading_15m
SELECT r.point_id, d.bucket, avg(r.value), count(*)
FROM rollup_dirty d
JOIN reading r ON r.point_id = d.point_id
              AND r.ts >= d.bucket AND r.ts < d.bucket + interval '15 minutes'
GROUP BY r.point_id, d.bucket
ON CONFLICT (point_id, bucket) DO UPDATE
SET avg_value = EXCLUDED.avg_value, samples = EXCLUDED.samples;
DELETE FROM rollup_dirty;
COMMIT;

SELECT bucket::text, samples FROM reading_15m
WHERE point_id = 1 AND bucket >= '2026-10-27 08:15+00' AND bucket < '2026-10-27 09:00+00' ORDER BY bucket;
Output
         bucket         | samples
------------------------+---------
 2026-10-27 08:15:00+00 |      15
 2026-10-27 08:30:00+00 |      15
 2026-10-27 08:45:00+00 |      15

Concurrency caveat for the educational example (not verified here)

Every statement on this page was run in a single session, so nothing here demonstrates behaviour under concurrent ingestion. The following is an analysis under PostgreSQL's default READ COMMITTED isolation, where each statement sees only data committed before it began. Without the LOCK TABLE line, two races would apply:

  • Markers lost between the two statements. The INSERT and the DELETE take separate snapshots. A marker for a new bucket committed by an ingest after the INSERT started is not recalculated, but the DELETE sees it and removes it, so that bucket is never refreshed until another late row touches it.
  • A marker that already existed. An ingest whose bucket is already marked inserts nothing into rollup_dirty (ON CONFLICT DO NOTHING). If the refresh runs before that ingest commits, it recalculates the bucket without the uncommitted rows and then deletes the marker. Deleting only the rows the refresh read (for example with DELETE ... RETURNING feeding the recalculation in one statement) fixes the first race but not this one.

Documented options, not verified here. The recommended path remains the window recompute above, whose correctness does not depend on locking that could not be exercised here. LOCK TABLE reading IN SHARE ROW EXCLUSIVE MODE conflicts with the ROW EXCLUSIVE lock that the ingest statement takes on reading, so the refresh waits for in-flight ingests to commit and blocks new ones until it commits; the mode is self-exclusive, so two refreshes cannot overlap either. This only works if everything that marks buckets dirty also writes to reading in the same transaction (the ingest above does). The price is that ingestion pauses while the refresh runs, so keep the dirty list small and the refresh frequent. Another option is to run ingest and refresh from one scheduler so they never overlap. TimescaleDB's continuous aggregates handle the same problem internally with an invalidation log: when data older than the invalidation threshold changes, each transaction logs the range it modified and the affected buckets are recalculated on the next refresh.

What late data does to storage

In-order data is appended to the newest partition, in time order. Late data is not, and each engine pays for that differently.

PostgreSQL: physical order and BRIN

A BRIN index works when a column correlates with physical position in the table, and pg_stats.correlation reports that correlation from -1 to +1. In the same run, the on-time load gave a correlation of 1.000 for ts; after the 30 late rows were appended it fell to 0.752. These are an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server. On a large table a sustained late-data share widens BRIN block ranges and makes the index less selective, so check the statistic and consider the minmax-multi operator class or a B-tree if it stays low. Late rows also need their partition to exist: do not drop or detach a partition while data for it can still arrive.

Correlation before and after the late batch (run on 18.6; illustrative measurement on a small PGlite sample, not a production benchmark)
ANALYZE reading;
SELECT attname, round(correlation::numeric, 3) AS correlation FROM pg_stats
WHERE tablename = 'reading' AND attname = 'ts';

-- after the on-time load:   ts | 1.000
-- after the late batch:     ts | 0.752

Engine behaviour from the documentation

  • QuestDB accepts rows in any timestamp order and merges them into position. The cost is write amplification, which its documentation says scales with partition size and how far behind the data arrives; it may split a partition rather than rewrite it whole. Its tuning advice is to fix accidental out-of-order sources first, then consider smaller partitions and deduplication for replays.
  • InfluxDB OSS v2 writes to the newest shard group normally; when backfilling it writes to older shards that must first be un-compacted, and re-compacts them afterwards.
  • TimescaleDB: the rowstore is designed for upserts and late-arriving rows, and columnstore chunks accept inserts without a manual decompress step. Continuous aggregates pick up changes below the invalidation threshold through the invalidation log, and a refresh policy only re-materializes buckets inside its window.
  • Kafka Streams: windowed aggregations wait for out-of-order records up to a configurable grace period and discard records that arrive after it.

The partition interval decides how much each late batch rewrites; see choosing time-series partition intervals.

Cross-database note: the same refresh in MySQL

MySQL has no date_bin or ON CONFLICT; bucket with UNIX_TIMESTAMP arithmetic and upsert with ON DUPLICATE KEY UPDATE. Referring to the new values with VALUES() is deprecated from MySQL 8.0.20, so the statement below selects from a derived table and refers to its alias. INSERT IGNORE is used for the duplicate here to keep the example short; it also turns some other errors into warnings, so prefer an explicit upsert in production.

Educational example: shows the idea in a single session; not safe for production as shown, because of the races described in the PostgreSQL section above. The script translates the dirty-bucket refresh and was run in a single session. Its concurrent behaviour differs from PostgreSQL's and was not verified here: InnoDB's default isolation level is REPEATABLE READ, and under it INSERT ... SELECT sets shared next-key locks on the rows it reads. Do not assume the race analysis above carries over either way. The window recompute idea (recompute every bucket in a recent window from the raw table on every run, plus a reconciliation) can be written in MySQL with the same ON DUPLICATE KEY UPDATE upsert, but its locking under InnoDB was not analysed or run here, so check it on your own server; serialising the job against ingestion (for example with LOCK TABLES ... WRITE, or one scheduler for both) remains a documented option, not verified here.

Educational example, not production-safe as shown: late batch and dirty-bucket refresh (run on MySQL 8.0.46, single session)
SET time_zone = '+00:00';

CREATE TABLE reading (
    point_id     INT NOT NULL,
    ts           DATETIME(3) NOT NULL,
    value        DOUBLE,
    received_at  DATETIME(3) NOT NULL,
    PRIMARY KEY (point_id, ts)
);
CREATE TABLE reading_15m (
    point_id   INT NOT NULL,
    bucket     DATETIME NOT NULL,
    avg_value  DOUBLE,
    samples    INT NOT NULL,
    PRIMARY KEY (point_id, bucket)
);
CREATE TABLE rollup_dirty (
    point_id  INT NOT NULL,
    bucket    DATETIME NOT NULL,
    PRIMARY KEY (point_id, bucket)
);

INSERT INTO reading VALUES
    (1, '2026-10-27 08:15:00', 21.15, '2026-10-27 08:15:05'),
    (1, '2026-10-27 08:16:00', 21.16, '2026-10-27 08:16:05');
INSERT INTO reading_15m VALUES (1, '2026-10-27 08:15:00', 21.155, 2);

-- Late batch: two readings for the same bucket arrive at 10:05; one is a duplicate
INSERT IGNORE INTO reading VALUES
    (1, '2026-10-27 08:16:00', 21.16, '2026-10-27 10:05:00'),
    (1, '2026-10-27 08:20:00', 21.20, '2026-10-27 10:05:00');
-- 1 row affected; Warning 1062: Duplicate entry '1-2026-10-27 08:16:00.000' for key 'reading.PRIMARY'

INSERT IGNORE INTO rollup_dirty
SELECT DISTINCT point_id, FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(ts) / 900) * 900)
FROM reading WHERE received_at = '2026-10-27 10:05:00';

INSERT INTO reading_15m (point_id, bucket, avg_value, samples)
SELECT * FROM (
    SELECT r.point_id, d.bucket, AVG(r.value) AS avg_value, COUNT(*) AS samples
    FROM rollup_dirty d
    JOIN reading r ON r.point_id = d.point_id
                  AND r.ts >= d.bucket AND r.ts < d.bucket + INTERVAL 15 MINUTE
    GROUP BY r.point_id, d.bucket
) AS s
ON DUPLICATE KEY UPDATE avg_value = s.avg_value, samples = s.samples;

SELECT point_id, bucket, ROUND(avg_value, 3) AS avg_value, samples FROM reading_15m;

-- point_id | bucket              | avg_value | samples
-- 1        | 2026-10-27 08:15:00 | 21.17     | 3

Worked example: a BMS gateway outage

A plant-room gateway loses its uplink for 30 minutes during a chiller trip, buffers its readings and reconnects. What each layer should do:

  1. Gateway: buffer locally with the original measurement timestamps, and send them oldest first on reconnect.
  2. MQTT broker: delivers what it receives; with QoS 1 a retry can deliver a reading twice. It is not a long-term buffer.
  3. Kafka (if used): keeps the events for its retention period, so the consumer can be rewound if the database was also down; a rewind replays rows already stored, which the primary key absorbs.
  4. Ingestion service: stamps received_at, applies the grace period and inserts with ON CONFLICT DO NOTHING, as above.
  5. Roll-up job: its next run recomputes the recent window, which still contains the outage period, so the chiller's 15-minute energy figures for the outage become complete.
  6. Alarm and CMMS rules: evaluate on event time, and treat a burst of late readings as history rather than new alarms, or a 30-minute-old high-temperature reading will raise a work order after the chiller has already restarted.

Trade-offs, operations and when not to use this pattern

  • Latency against correctness. A longer grace period keeps reports correct for longer but delays the point at which they are final. Kafka Streams' documentation describes the same trade-off between latency, cost and correctness.
  • Ingest time as the main timestamp. QuestDB suggests that when most data arrives far behind its event time, making ingestion time the designated timestamp turns the workload into appends, at the cost of event-time queries reading more partitions. Our editorial view: it is rarely worth it for building data, where reports are about event time.
  • Restated reports. If closed reports can change, keep a restatement log so you can show what changed and why.
  • Monitoring: track p99 lateness, the share of out-of-order rows, quarantined rows per gateway, and the duration of each roll-up run. A sudden change usually means a clock, network or consumer problem, not a change in the building.
  • Security: the ingestion role needs insert on readings, the dirty list and the quarantine table; only an operator role should be able to release quarantined rows.
  • When not to bother: if nothing is aggregated or reported until the next day, a nightly roll-up of the previous day absorbs most late data without any of this machinery.

Frequently asked questions

What is out-of-order time series data?

Readings whose event time is earlier than a reading already received for the same series. It is common when gateways buffer data during outages, when consumers replay a stream, or when device clocks drift.

What is the difference between late and out-of-order data?

Out of order is about sequence: the reading arrived after a newer one. Late is about processing: it arrived after its time window was already aggregated or reported. They usually go together, but a reading can be slightly out of order without being late, and the other way round.

Should I store the time a reading was received?

Yes. Keep event time as the key and add the ingest time as a column. Without it you cannot measure lateness, apply a grace period or explain why a report changed.

How do I update roll-ups when late data arrives?

The approach recommended on this page is to recompute every bucket in a bounded recent window (sized from your grace period) from committed data on every run with an upsert, and to reconcile older periods on a slower schedule. Recording only the buckets late rows touched is cheaper but, as shown here, races with concurrent ingestion, so it is kept as an educational example. TimescaleDB continuous aggregates track changes automatically through an invalidation log, within the refresh window of their policy.

Does late data slow down a time-series database?

Usually somewhat. It is written into older partitions, reduces physical time ordering, and in some engines forces partitions to be rewritten or shards to be un-compacted. Vendors document the mechanisms; measure the effect on your own workload.

Sources

Checked 27 October 2026.

How we research these articles: our editorial method. Research-based from official documentation; SQL examples are run on a local database where the page says so. CodeWithSQL earns nothing from the vendors mentioned.

Building a sensor or telemetry database?

Start with the overview, or compare time-series databases side by side.