Concluded·3 Aug 2026 · 5 min read

Ten million plant readings on one small box, and the mistake that cost 5.6×

Node.js 20 · TimescaleDB pg17 · Mosquitto 2 · Docker Compose · Proxmox LXC

Before anyone buys storage for a historian, somebody usually runs a load test. I wanted to know what a good one looks like, so I built the generator, then tried to break my own numbers.

Two things came out of it that I did not expect, and one of them would have cost real money.

What the generator produces

Six kinds of tag, mixed so that slow analogue values dominate as they do on a real site: temperature, flow, pressure, vibration, a production counter and a discrete state. Ten thousand tags, one-second sampling.

The models are deliberately not noise:

KindBehaviour
temperatureslow drift, a daily cycle, a step at shift change
flowflat at zero, then flat at rate, when a pump starts
pressurefollows flow, with its own small noise
vibrationquiet, occasional spikes, a slow creep on ~1 asset in 23
countermonotonic, never decreases
statediscrete, holds for hours

WHAT PLANT DATA ACTUALLY LOOKS LIKE

temperature
drifts, then holds
flow
flat, then the pump starts
pressure
follows the flow
vibration
quiet, with a fault creeping in
counter
only ever goes up
state
sits still for hours
Real output from the generator, not sketches. Every one of these shapes is repetition a historian can squeeze. Random numbers have none of it.

Every value is a pure function of (seed, tagIndex, sampleIndex), so a run reproduces exactly and nothing has to be stored to repeat it. That matters more than it sounds: it means the ClickHouse comparison later can be fed byte-identical data without shipping a dataset around.

Where the time goes

One million readings through each stage, single process, each row adding the next real cost:

StagePoints/sec
work out the values3,164,557
+ serialise to JSON1,158,749
+ publish over MQTT211,193
+ batched multi-row INSERT166,722
+ COPY, a row at a time54,888
+ COPY, in 2,000-row chunks309,502

ONE MILLION READINGS, ONE CORE

3,164,557
1,158,749
211,193
166,722
54,888
THE SAME COPY, WRITTEN IN CHUNKS — 309,502/sec

Buffering the rows and writing two thousand at a time made it 5.6 times faster and moved it from slowest to fastest. Nothing about the database changed.

Each step adds the next real cost. Generating the data is never the expensive part — everything after it is.

Producing the readings is nearly free. A single core makes over three million a second, and JSON costs about two thirds of that (62 MB of text per million readings). MQTT publishing dropped it to 211,000 and, worth noting for anyone load-testing a broker, resident memory climbed to 706 MB as the client buffered ahead of the socket.

The mistake, and the wrong guess before it

COPY is supposed to be the fast path into Postgres. Mine came out three times slower than ordinary batched inserts, which is the wrong way round and therefore worth chasing rather than reporting.

First guess, reasonable, wrong. Every tag in a sample shares one timestamp, and the code was rebuilding the ISO string once per reading rather than once per sample. Hoisting it out of the inner loop took 49,945 → 54,888. Real, about ten per cent, and not the answer.

The actual cost was one stream.write() per reading. Each write carries the same fixed overhead whether it holds forty bytes or forty thousand, and I was paying it ten million times over, plus a backpressure check each time. Buffering rows and writing in chunks:

Chunk sizePoints/sec
a row at a time54,888
500295,508
2,000309,502
20,000280,112

5.6× faster, and it moved COPY from the slowest option in the table to the fastest. Chunk size barely matters past a few hundred, so there is no tuning exercise here — there is just one thing you must not do.

The general lesson is the one I keep relearning: when a well-known fast path measures slow, the tool is usually fine and the loop around it is not.

More cores barely helped

Two million readings, varying worker processes. Node runs one thread, so parallelism means separate processes, each owning a disjoint slice of tags.

WorkersPoints/sec
1304,414
2287,936
4352,734
6397,772

ADDING CORES TO A DATABASE PROBLEM

0k500k1000k1500k1246worker processesperfectactual
Six cores bought 1.31×, not 6×. The generator was never the constraint — the database absorbing the writes is.

Six cores bought 1.31×. Two workers were briefly worse than one. The generator can produce 3.1 million readings a second on one core while six parallel COPY streams land 398,000, which puts the ceiling somewhere it is easy to misattribute.

THE CONSTRICTION

3,164,557/sone core, generating397,772/ssix cores, landingthe database
If you are planning to buy a bigger machine to generate test load, you are buying the wrong end of this picture.

If a load test is running too slowly, a bigger box for the generator will do almost nothing. The database is the constriction.

The full run

Points10,000,000
Workers6
Time26.1 s
Rate382,555 points/sec
Terminal output showing ten million realistic points landed in 26.1 seconds at 382,555 points per second across six workers, and ten million random points landed in 27.6 seconds at 362,463 per second.
Both arms, six workers each. The random arm is marginally slower only because it calls the random number generator per value.

On a shared box with several other containers running. Simulating a plant's worth of history is not a hardware problem.

The finding that costs money

Two identical tables. Same schema, same 10,000,000 rows, same database, same compression settings. One holds plant-shaped data; the other holds uniform random numbers, which is what a quickly-written generator emits.

ArmRawCompressedRatioBytes per reading
shaped like a plant695 MB16.2 MB43.0×1.69
uniform random696 MB81.7 MB8.5×8.57

10,000,000 READINGS, COMPRESSED

shaped like a plant43.0× · 1.69 bytes per reading
695 MB raw16.2 MB
uniform random numbers8.5× · 8.57 bytes per reading
695 MB raw81.7 MB
Identical row counts, identical schema, identical compression settings. The only difference is whether the numbers behave like a plant.
Terminal output showing both tables at 695 and 696 megabytes before compression, then 16 megabytes and 82 megabytes after, with exact byte counts giving 1.69 bytes per reading for plant-shaped data and 8.57 for random.
The same command against both tables. Nothing differs but the shape of the numbers inside them.

Compression works by finding repetition. Plant data is full of it — states that hold, counters that step by a constant, analogues that wander between neighbouring values. Random numbers contain none, so there is nothing to remove.

A load test built on noise understates compression by five times. It is not an obviously broken test. It runs, it produces a number, and the number is confidently wrong in the direction that makes you buy hardware.

THE NUMBERS WORTH REMEMBERING

26.1 s
TO MAKE 10 MILLION READINGS
1.69
BYTES PER READING, STORED
53 MB
ONE TAG, EVERY SECOND, FOR A YEAR
THE ERROR IF YOU TEST WITH NOISE
The last one is the only figure here that costs money. It is the difference between sizing a disk correctly and buying five times too much.

At 1.69 bytes per stored reading, one tag sampled every second for a year is about 53 MB. A thousand of them, roughly 53 GB a year.

What I would tell someone repeating this

Model the shape, not the statistics. You do not need a physics model; you need values that hold still, values that step, and counters that only climb. That is an afternoon of work and it is the difference between a storage estimate you can sign and one that is out by five times.

Write in chunks. Check your loading loop before you blame the engine.

And do not buy a bigger machine for the generator. It was idle.

What this feeds

The ten-million-row table is the input for the ClickHouse against TimescaleDB comparison, so both engines get measured on identical, realistically shaped data rather than on noise.

The argument, without the measurements, is in the article.

Newsletter

New essays, by email.

SCADA, cloud, AI, and the plant floor — a short email when something new is published. No noise, unsubscribe anytime.