DEV Community

Usman Khan
Usman Khan

Posted on Originally published at ctousman.com

PostgreSQL MVCC Autovacuum Starvation: HOT Update Breakdown & Table Bloat under Heavy Write Loads

When PostgreSQL tables scale past hundreds of millions of rows under high-frequency updates, default autovacuum configurations quietly break. The failure of Heap Only Tuple (HOT) updates triggers exponential index bloat, disk I/O thrashing, and database starvation. Here is an architectural deep dive into diagnosing HOT update degradation, tuning fillfactors, and optimizing autovacuum for heavy SaaS write workloads.

In high-throughput B2B SaaS platforms, relational databases frequently handle thousands of state mutations per second: updating tenant session tokens, subscription statuses, or webhook dispatch counters. Under default PostgreSQL settings, Multi-Version Concurrency Control (MVCC) mechanics can silently trigger catastrophic table and index bloat, degrading query performance from single-digit milliseconds to multi-second timeouts.

1. The Mechanics of MVCC and the HOT Update Failure Vector

PostgreSQL handles concurrent operations via MVCC. When an application executes an UPDATE statement, Postgres does not overwrite the existing row on disk in place. Instead, it creates a new row version (a new tuple) and marks the old tuple as dead. Dead tuples remain on disk until cleaned up by the autovacuum daemon process.

How Heap Only Tuple (HOT) Updates Prevent Index Bloat

Normally, inserting a new tuple requires updating every index attached to the table. To avoid this massive I/O penalty, Postgres uses Heap Only Tuple (HOT) updates. A HOT update occurs when:

  • The UPDATE does not modify any column referenced by a table index.
  • The target page in heap memory has sufficient free space to store the new tuple version alongside the old one.

When HOT criteria are met, Postgres links the new tuple directly from the old tuple inside the same data page. B-Tree indexes continue pointing to the original root tuple, completely avoiding index write amplification.

The Silent Breakdown Under Production Load

When an application updates an indexed column (such as updating updated_at or last_seen_at where an index exists), or when heap pages fill up completely (100% page density), HOT updates fail completely. Every row update forces new index pointers into every B-tree index on the table, resulting in severe index bloat that autovacuum cannot reclaim efficiently.

2. Diagnosing HOT Update Failure and Autovacuum Starvation

To detect if your write-heavy tables are suffering from HOT update degradation, run the following diagnostic query on your production PostgreSQL instance:

-- Diagnostic Query: Evaluating HOT Update Success Ratios across Tables
SELECT
    schemaname,
    relname AS table_name,
    n_tup_upd AS total_updates,
    n_tup_hot_upd AS hot_updates,
    ROUND(
        (n_tup_hot_upd::float / NULLIF(n_tup_upd, 0)::float) * 100
    ) AS hot_update_ratio_pct,
    n_dead_tup AS dead_tuple_count,
    last_autovacuum
FROM pg_stat_user_tables
WHERE n_tup_upd > 10000
ORDER BY n_tup_upd DESC;
Enter fullscreen mode Exit fullscreen mode

If your hot_update_ratio_pct falls below 80–90% on a table experiencing heavy updates, your database is actively incurring massive index write amplification and page fragmentation.

3. Architectural Remediation: Table Fillfactor & Autovacuum Tuning

Step 1: Setting Table-Level Fillfactors for HOT Space Allocation

By default, PostgreSQL fills heap pages to 100% capacity during initial INSERT operations. Consequently, subsequent UPDATE calls have zero free space on the page for a new HOT tuple. Reduce the table fillfactor to reserve space on heap pages explicitly for row updates.

-- Reduce page density to 80% to reserve 20% page space for HOT updates
ALTER TABLE tenant_session_states SET (fillfactor = 80);

-- Rewrite existing pages so the new fillfactor applies to old data too.
-- VACUUM FULL takes an exclusive lock; in production, use pg_repack instead.
VACUUM FULL tenant_session_states;
Enter fullscreen mode Exit fullscreen mode

Step 2: Aggressive Per-Table Autovacuum Tuning

Global default autovacuum parameters are far too conservative for high-volume SaaS databases. Instead of waiting for 20% of a multi-million row table to turn into dead tuples before vacuuming, configure aggressive, scale-factor thresholds on write-heavy tables:

-- Configure Aggressive Autovacuum Parameters for High-Throughput Write Tables
ALTER TABLE tenant_session_states SET (
    autovacuum_vacuum_scale_factor = 0.02,     -- Trigger vacuum after 2% row changes (default is 20%)
    autovacuum_vacuum_threshold = 1000,        -- Minimum dead tuple threshold
    autovacuum_vacuum_cost_limit = 2000,       -- Increase I/O credit allocation for this table
    autovacuum_vacuum_cost_delay = 2           -- Reduce I/O delay between vacuum cycles (ms)
);
Enter fullscreen mode Exit fullscreen mode

4. Removing Redundant & Unused Indexes

Every additional index on a table reduces the probability of a HOT update. Eliminate synthetic indexes on frequently updated columns (such as timestamps used strictly for auditing rather than filtering) and drop duplicate B-tree indexes.

-- Identifying Unused Indexes Causing HOT Update Blockers
SELECT
    schemaname,
    relname AS table_name,
    indexrelname AS index_name,
    idx_scan AS number_of_scans,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
  AND indisunique = false
ORDER BY pg_relation_size(indexrelid) DESC;
Enter fullscreen mode Exit fullscreen mode

5. Executive Summary & Systems Rules

  • Isolate Frequently Mutated Attributes: Move fast-changing counter columns (e.g., last_active_at, login_count) into a dedicated secondary state table to protect the primary table from HOT update failures.
  • Never Leave High-Write Tables at Fillfactor 100: Set fillfactor = 70-80 on high-frequency update tables to give PostgreSQL the required heap page room for HOT tuples.
  • Tune Autovacuum per Table: Override global defaults on high-volume tables to vacuum dead tuples incrementally before index bloat degrades query execution plans.

Is table bloat or autovacuum lag slowing down your Postgres database? I help SaaS teams audit database performance, tune autovacuum and fillfactors, and design multi-tenant data architectures that hold up under heavy write loads. Book a consultation →

Originally published on ctousman.com.

Top comments (0)