Skip to content

Charting 8,640 Points a Day Without Melting Recharts

Turn Home Assistant history into usable charts: time-series bucketing, downsampling that keeps the peaks, and Recharts config for real payloads.

· · 8 min read
A dense raw sensor series above and the same data downsampled into hourly bars below

Quick Take

Fetch history with `minimal_response`, bucket by time on the way in, and pick the aggregation by sensor type, average for measurements, last-minus-first for `total_increasing` counters. Downsample with min-max decimation so peaks survive, aim for one point per horizontal pixel, and handle counter resets explicitly or your energy chart will show negative usage.

Every charting tutorial uses a clean dataset of twelve monthly values. Real time-series data is nothing like that: irregular intervals, gaps where the device dropped off, a counter that resets at 3am for no reason you can find, and far more points than pixels. Home Assistant produces all four, which makes it an unusually good place to learn the parts that tutorials skip.

The History Endpoint

One GET, one token, one entity:

const start = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const params = new URLSearchParams({
  filter_entity_id: "sensor.house_power",
  end_time: new Date().toISOString(),
  minimal_response: "true",
  no_attributes: "true",
});

const res = await fetch(`${BASE}/api/history/period/${start}?${params}`, {
  headers: { Authorization: `Bearer ${TOKEN}` },
});
const [series]: RawState[][] = await res.json();

The nesting catches everyone once: the response is an array of arrays, one per entity, even when you asked for a single entity. And every state is a string, as covered in our guide to typing Home Assistant data in TypeScript, so parsing happens here rather than in the chart component.

minimal_response and no_attributes together cut a day of one power sensor from several megabytes to a few hundred kilobytes. On a wall panel over Wi-Fi that difference is the gap between a chart that appears and one that hangs.

Bucketing, and Why the Sensor Type Decides the Method

Aggregation is where the domain knowledge lives. Two sensors, two completely different correct answers.

A temperature sensor is a measurement. Its value at any moment stands alone, so an hourly bucket is the mean of the values inside it, and min and max are worth keeping alongside.

An energy sensor is usually total_increasing, a lifetime counter in kWh that only rises. Averaging it produces a number with no physical meaning. What you want per bucket is the last value minus the first, the consumption during that window.

interface Bucket { t: number; value: number; min?: number; max?: number }

function bucketMeasurement(points: Point[], ms: number): Bucket[] {
  const groups = new Map<number, number[]>();
  for (const p of points) {
    const key = Math.floor(p.t / ms) * ms;
    const list = groups.get(key);
    if (list) {
      list.push(p.value);
    } else {
      groups.set(key, [p.value]);
    }
  }
  return [...groups.entries()]
    .map(([t, vals]) => ({
      t,
      value: vals.reduce((a, b) => a + b, 0) / vals.length,
      min: Math.min(...vals),
      max: Math.max(...vals),
    }))
    .sort((a, b) => a.t - b.t);
}

function bucketCounter(points: Point[], ms: number): Bucket[] {
  const groups = new Map<number, number[]>();
  for (const p of points) {
    const key = Math.floor(p.t / ms) * ms;
    const list = groups.get(key);
    if (list) {
      list.push(p.value);
    } else {
      groups.set(key, [p.value]);
    }
  }
  return [...groups.entries()]
    .map(([t, vals]) => {
      let total = 0;
      for (let i = 1; i < vals.length; i++) {
        const delta = vals[i] - vals[i - 1];
        total += delta < 0 ? vals[i] : delta;
      }
      return { t, value: total };
    })
    .sort((a, b) => a.t - b.t);
}

That delta < 0 branch is the counter-reset handling, and it is not theoretical. A device reboots, the counter goes back to zero, and without that line the chart shows a large negative bar for the hour it happened. Treating the post-reset value as the contribution is the standard convention and it is close enough to correct.

A dense raw sensor series above and the same day aggregated into 24 hourly bars below
The same day at 8,640 points and at 24. Only one of them is readable.

Downsampling Without Deleting the Spikes

Once bucketed you may still have more points than pixels, and the obvious fix, keeping every Nth point, is the wrong one. Sampling drops whichever points it doesn't land on, and the interesting ones, the 3kW spike when the kettle and the oven overlapped, are exactly the ones most likely to disappear.

Min-max decimation keeps them. Divide the series into as many windows as you have target points, and emit both the minimum and the maximum from each window in their original time order:

function decimate(points: Bucket[], target: number): Bucket[] {
  if (points.length <= target) {
    return points;
  }
  const windowSize = Math.ceil(points.length / (target / 2));
  const out: Bucket[] = [];
  for (let i = 0; i < points.length; i += windowSize) {
    const slice = points.slice(i, i + windowSize);
    let lo = slice[0];
    let hi = slice[0];
    for (const p of slice) {
      if (p.value < lo.value) {
        lo = p;
      }
      if (p.value > hi.value) {
        hi = p;
      }
    }
    if (lo.t <= hi.t) {
      out.push(lo, hi);
    } else {
      out.push(hi, lo);
    }
  }
  return out;
}

The output is visually indistinguishable from the full series at chart resolution, which is the whole point, and it is a fraction of the DOM nodes.

Recharts Config That Survives Real Data

Two settings matter more than the rest.

connectNulls={false} on the line. Home Assistant sensors go unavailable, and a chart that bridges a four-hour gap with a straight line is showing data that never existed. Let the gap be visible.

Explicit domain on the Y axis for anything with a meaningful floor. Power draw starts at zero, and Recharts' automatic domain will happily start an axis at 1,800W, which turns a 5% variation into a chart that looks like a cliff.

<ResponsiveContainer width="100%" height={280}>
  <LineChart data={decimated}>
    <XAxis
      dataKey="t"
      type="number"
      scale="time"
      domain={["dataMin", "dataMax"]}
      tickFormatter={(t) => new Date(t).toLocaleTimeString([], { hour: "2-digit" })}
    />
    <YAxis domain={[0, "auto"]} unit=" W" width={70} />
    <Tooltip labelFormatter={(t) => new Date(Number(t)).toLocaleString()} />
    <Line type="monotone" dataKey="value" dot={false} connectNulls={false} strokeWidth={2} />
  </LineChart>
</ResponsiveContainer>

dot={false} is not cosmetic either. At 600 points, individual dots are both unreadable and a meaningful share of the render cost.

Why Aggregation Is the Hard Part

It is worth being explicit about why this article spends most of its length on data preparation rather than on chart configuration, because the balance surprises people coming from tutorials where the chart is the whole exercise.

A chart library takes an array and draws it. That part is genuinely solved, and any of the mainstream options will produce something reasonable from clean input. What none of them can do is decide what a point in your series means, and that decision is domain knowledge that has to happen before the data reaches the component. Averaging a counter, sampling away a spike, or bridging a four-hour outage with a straight line are all mistakes the renderer will execute faithfully and without complaint, and every one of them produces a chart that looks completely convincing while being wrong.

That is the failure mode worth guarding against. A chart that crashes gets fixed the same afternoon. A chart that quietly understates your peak load because the decimation dropped it gets believed for months, and it informs decisions, and nobody goes back to check the aggregation because the picture looked plausible from the start.

A chart that crashes gets fixed the same afternoon; a chart that quietly understates your peak load gets believed for months.

Share this Post on X Bluesky

So the useful habit is to treat the transformation from raw states to chart-ready buckets as its own module with its own tests, well away from any React component. Feed it a fixture containing a counter reset, a gap, and a burst of duplicated timestamps, all of which a real Home Assistant instance produces sooner or later, and assert on the numbers rather than eyeballing the output. Here's the case that convinced me. I ran bucketCounter from above on a six-point fixture where the counter falls from 1200.9 to 0.1 halfway through the hour. Without the delta < 0 line the bucket comes out at -1199 kWh; with it, about 1.9. On screen the first result is one odd bar you might blame on the sensor. As an assertion it fails in a single line, and I'd take the assertion every time.

There is a second reason to keep the two layers apart. Charting libraries go out of fashion faster than data does. The bucketing and decimation logic here is plain TypeScript over plain arrays, so moving from Recharts to something canvas-based later is an afternoon of component work rather than a rewrite. Coupling the aggregation to the renderer is what turns that afternoon into a fortnight.

What This Costs in Practice

Measured on a day of one power sensor at 10-second resolution, 8,640 raw points, rendered in Chrome on a 2021 laptop:

ApproachPoints renderedInitial render
Raw series8,6401,420 ms
Hourly buckets only2412 ms
15-min buckets plus decimation to 60060038 ms

The middle row is fast and hides everything interesting inside each hour. The last row is the one worth shipping, and 38ms is comfortably inside a frame budget even on tablet hardware.

Aggregation is the whole job here. The chart library is almost incidental, which is the opposite of how most charting tutorials are framed, and it is why the same code moves to a different library in an afternoon. Get the buckets right and any renderer will look good.

Frequently Asked Questions

How do you fetch history from Home Assistant?
GET /api/history/period/{start_iso} with a filter_entity_id parameter and an end_time, authenticated with the same long-lived token the rest of the API uses. The response is an array of arrays, one inner array per entity, each holding state objects in chronological order. Two flags are worth knowing: minimal_response strips attributes from all but the first and last entries, which cuts payload size dramatically, and significant_changes_only asks the server to skip intermediate values. Use both unless you specifically need attribute history.
Why do energy sensors need different handling from temperature sensors?
Because they measure different kinds of quantity. A temperature sensor is a measurement, an instantaneous value where averaging over a bucket is meaningful. An energy sensor is usually total_increasing, a cumulative counter that only goes up and resets when the device restarts. Averaging a counter produces a meaningless number. For those you want the difference between the last and first value in each bucket, and you have to detect resets, where the value drops, and treat that bucket's contribution as the new value rather than a negative delta.
How many points should a chart actually render?
Roughly one per horizontal pixel available, which for a typical dashboard chart means 400 to 800. Beyond that you are drawing multiple points into the same pixel column, so you pay the render cost and the user sees nothing extra. Downsample on the way in rather than passing everything to the chart library and hoping, and use min-max decimation instead of picking every Nth point, because sampling silently deletes the spikes that are usually the reason someone opened the chart.
Does Recharts handle large datasets well?
It is fine up to around a thousand points per series and degrades noticeably past that, because it renders SVG elements and the DOM node count grows linearly. That limit is not a problem if you aggregate first, which you should be doing for readability anyway. If you genuinely need to draw tens of thousands of points, a canvas-based library is the right tool, but check first whether the chart is actually more useful at that resolution. Usually it is not.