Store.upsert as a Postgres INSERT ... ON CONFLICT ... DO UPDATE, and each update leaves behind a dead row version that has to be cleaned up later. On the default table settings this churn bloats the table and its indexes, and query latency drifts upward until autovacuum catches up.
This page explains why that happens and the three changes that keep these tables lean. The biggest lever is in your handler code; the other two are one-line Postgres settings applied through a migration.
Why update-heavy tables slow down
Postgres never overwrites a row in place. AnUPDATE (and an upsert that hits an existing row) writes a new version of the row and marks the old one dead. Autovacuum reclaims dead versions later. Until it does, the table — and every index on it — carries the extra dead entries, so scans read more pages and queries slow down.
There is a fast path that avoids most of this cost: a HOT (Heap-Only Tuple) update. When the new row version fits on the same 8kB page as the old one and no indexed column’s value changes, Postgres links the versions on the page and skips updating the indexes entirely. HOT updates produce far less bloat and are cleaned up cheaply.
Two conditions must hold for an update to stay HOT:
- No indexed column’s value actually changes. Postgres compares stored values, not the
SETlist — rewriting a column to the value it already holds does not break HOT. - The new version fits on the same page. This requires free space on the page, which the default table layout does not reserve.
1. Skip no-op writes in your handler
Before adding an entity to your save batch, compare it against the loaded copy and drop it if nothing changed - this will prevent invalidating rows for no reason. Starting from the batch-processing pattern, keep only the entities that differ, e.g.original is a snapshot of the entities as they were loaded, and isDifferent compares the fields your handler may have touched:
2. Reserve page space with fillfactor
fillfactor is the percentage of each heap page Postgres fills when inserting rows; the rest is reserved for future updates. At the default fillfactor = 100 there is no headroom, so an updated row almost never fits on its original page and the HOT fast path is lost — every update bloats the heap and all indexes.
Lowering fillfactor to 90 leaves 10% of each page free, dramatically raising the share of updates that stay HOT. On an update-heavy table this typically moves the HOT rate from a few percent to a clear majority and roughly halves heap bloat. The cost is modest: rows are packed slightly less densely, so a cold table takes marginally more disk.
3. Vacuum more often with autovacuum_vacuum_scale_factor
Autovacuum fires for a table once its dead tuples exceed autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * row_count. The default scale_factor of 0.2 means a table must be roughly 20% dead before it is cleaned. On a large, update-heavy table that lets bloat — and query latency — swing badly between cycles.
Setting scale_factor = 0.05 triggers a vacuum at about 5% dead, giving smaller, more frequent cleanups and steadier query performance, at the cost of a little more background autovacuum I/O.
scale_factor never trips. Drive its autovacuum off the absolute threshold instead:
Applying the table tuning in a migration
fillfactor and the autovacuum settings live in Postgres, not in schema.graphql, so squid-typeorm-migration generate will never produce them. Add a small hand-written migration alongside the generated ones.
The core of the migration is one ALTER TABLE per update-heavy table:
down:
db/migrations/NNNN-TuneUpdateHeavyStorage.js
db/migrations/NNNN-TuneUpdateHeavyStorage.js
Reclaiming existing bloat
The settings above keep new churn cheap but do not repack data that is already bloated, and loweringfillfactor only changes pages written from then on. To rewrite an existing table compactly under its new fillfactor, run a one-time repack.
VACUUM FULL is built in but takes an ACCESS EXCLUSIVE lock for the whole rewrite, blocking all reads and writes — only suitable when the squid is paused:
If downtime is unacceptable, use a zero-downtime update: deploy to a fresh slot whose database is built with the tuning migration from block zero, then move your production tag to it. Alternatively, contact us to arrange an online repack if you are an enterprise customer.
Verifying the effect
Check the HOT rate and bloat for your tables inpsql (DB access):
hot_pct and a n_dead_tup count that stays low relative to its size. A low hot_pct after tuning usually means an indexed column’s value really is changing on every update — revisit step 1 and your @index choices.
Related
- Batch processing — the in-memory batching that no-op skipping builds on.
- Saving to PostgreSQL —
Store.upsertand the migration workflow. - Query optimization — the read-side counterpart: indexing slow GraphQL queries.
- Indexes and constraints —
@indexdecorators; fewer indexes also means fewer to keep HOT.