Table of Contents

Latency heatmaps

Install: dotnet add package MatPlotLibNet

A latency heatmap answers the question an average cannot: did the slow requests get slower, or are there simply more of them? It is a histogram over time. Each column is one slice of the clock, each row is one latency bucket, and the colour of a cell is how many requests landed in that bucket during that minute.

Latency heatmap

Read it as a shape. The bright band is where most requests are. When it drifts upward, everything got slower. When a second band appears above the first, one code path got slow while the rest stayed fine — and that is exactly the case a p99 line reports as "the p99 rose" without saying why.

The recipe

Use Pcolormesh. It takes the edges of the cells rather than a bare matrix, which is what lets the bottom axis carry a real clock and the side axis carry real millisecond bucket edges. There is one more edge than there are cells along each direction: 31 minute boundaries make 30 columns, 11 bucket edges make 10 rows.

using MatPlotLibNet.Models;
using MatPlotLibNet.Styling.ColorMaps;

var start = new DateTime(2026, 9, 12, 14, 0, 0, DateTimeKind.Utc);

// One edge more than there are cells, in both directions.
double[] minutes = [.. Enumerable.Range(0, 31).Select(i => start.AddMinutes(i).ToOADate())];
double[] buckets = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500];      // milliseconds

// counts[bucket, minute] — how many requests fell in that bucket in that minute.
var counts = new double[buckets.Length - 1, minutes.Length - 1];

Plt.Create()
    .WithTitle("Request latency — one column per minute")
    .WithTheme(Theme.OpsPanel)
    .WithSize(1000, 520)
    .AddSubPlot(1, 1, 1, ax => ax
        .Pcolormesh(minutes, buckets, counts, s =>
        {
            s.ColorMap = PerceptualColorMaps2.Rocket;
            s.Label = "requests";                 // names the value column of the data table
        })
        .SetXDateAxis()                       // the edges are OLE dates; this makes them read as a clock
        .SetYScale(AxisScale.Log)             // latency is log-distributed, so the buckets are too
        .SetXLabel("Time (UTC)")
        .SetYLabel("Latency (ms)")
        .WithColorBar(cb => cb with { Label = "requests" }))
    .TightLayout()
    .Save("latency.svg");

Three things make it a latency heatmap rather than a grid of coloured squares:

What Why
Pcolormesh, not Heatmap A heatmap takes a bare matrix and numbers its cells 0, 1, 2. A mesh takes the coordinates you give it, so a minute stays a minute.
SetXDateAxis() A date is stored as a number. Without this the reader sees 46277.58 instead of 14:15.
SetYScale(AxisScale.Log) Latency spans decades. On a linear axis the 1-to-25 ms rows collapse into a line and the tail takes the whole panel.

The cells reach the spines on all four sides — a mesh fills its plot area exactly, the way a heatmap does, so there is no gutter between the outermost cells and the frame.

Choosing the bucket edges

The edges are yours to pick, and they do not have to be evenly spaced. A 1-2-5 ladder over each decade is the usual choice because it puts roughly the same number of buckets in every decade:

double[] buckets = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500];

Two rules worth keeping:

  • Put the ceiling where your timeout is. A request that takes longer than the timeout is a different event, and giving it its own top bucket keeps it from stretching the axis over everything else.
  • Do not change the edges while a screen is up. An operator learns where the rows are. Moving them means the picture changes without the system changing.

Filling the counts

The library draws the histogram; counting is yours. The shape is one pass over the samples:

static int BucketOf(double milliseconds, double[] edges)
{
    for (int i = 1; i < edges.Length; i++)
    {
        if (milliseconds < edges[i]) return i - 1;
    }

    return edges.Length - 2;                 // anything past the top edge belongs in the top bucket
}

foreach (var sample in samples)
{
    int minute = (int)(sample.At - start).TotalMinutes;
    if (minute >= 0 && minute < counts.GetLength(1))
    {
        counts[BucketOf(sample.Milliseconds, buckets), minute]++;
    }
}

Reading the numbers back

figure.ToDataTables() gives the same data as a table, one row per cell, named by the clock time and the bucket edge the cell starts at rather than by a row and column number. That is what a screen reader reads, what the chart_data_table tool hands an AI agent, and what ToCsv() writes. Give the series a label and it names the value column:

ax.Pcolormesh(minutes, buckets, counts, s => s.Label = "requests")
Time (UTC) y requests
2026-09-12 14:00 25 318
2026-09-12 14:01 25 297
2026-09-12 14:00 50 402

The first column takes the name of the x-axis label and is spelled as a clock, because the column is marked as a position on that axis. The second keeps the plain name y: a y column usually carries a series name, and the table would rather repeat y than throw a series name away.

A percentile line on top

A heatmap shows the distribution; a line shows the summary. Drawing the p99 over the mesh gives both, and the line lands in the right place because both are in the same data space:

ax.Pcolormesh(minutes, buckets, counts)
  .Plot(minuteCentres, p99PerMinute, s => { s.Label = "p99"; s.LineWidth = 2; })
  .SetYScale(AxisScale.Log)
  .WithLegend();

See also