EnorexaEnorexa
Back to blog

Engineering

PostgreSQL JSONB TOAST Bloat: Why Mutating AI Agent State Kills IOPS

November 2, 20264 min readBy Enorexa Team

Mutating a 60KB JSONB column twenty times during a single AI workflow does not update a few bytes in place—it writes 1.2MB of dead tuples directly to disk. We recently audited a Series A logistics platform whose AWS RDS storage IOPS spiked to 100% every afternoon at 2:00 PM. Their CPU utilization sat below 30%. Active user traffic was flat. The culprit was a single agent_runs table storing conversation history, tool outputs, and OCR bounding boxes inside one mutable state column.

How Postgres MVCC Turns JSONB Updates Into Write Amplification

Most application engineers treat jsonb_set() like an in-memory JavaScript object mutation. Postgres does not work that way. Under Multi-Version Concurrency Control (MVCC), any UPDATE creates a brand-new row version. Worse, whenever a compressed row exceeds 2KB (TOAST_TUPLE_THRESHOLD), Postgres slices the payload into chunks and moves it out-of-line into a secondary TOAST (pg_toast) table. Because JSONB is stored as a contiguous binary tree after compression, Postgres cannot patch a single nested key inside a TOASTed value. If your Node.js or Python worker updates state.step_status from running to completed on an 80KB payload, Postgres copies and rewrites the entire 80KB blob into new TOAST chunks, writes the full change to the Write-Ahead Log (WAL), and leaves the old 80KB chunks behind as dead tuples.

When Autovacuum Falls Behind Your Worker Queue

Now multiply that behavior across 50 concurrent background workers executing a 12-step extraction and verification pipeline. A single workflow run generates nearly 1MB of dead TOAST tuples in under thirty seconds. Default Postgres settings (autovacuum_vacuum_scale_factor = 0.2) wait until 20% of a table changes before triggering cleanup. On a 5-million-row table, autovacuum shows up far too late. Worse, if you attach a GIN index (jsonb_path_ops) to that mutable JSONB column, every single step update forces index maintenance across hundreds of extracted keys. Your pg_stat_user_tables view will show millions of n_dead_tup, query latency on simple primary-key lookups will climb from 2ms to 450ms, and RDS burst balances will drain to zero.

Decoupling Hot Execution State From Cold Payload Storage

Fixing this requires three concrete schema changes that you can ship in a single migration without adding new infrastructure:

  1. Promote mutable flags to native columns: Pull status, current_step, retry_count, and error_code out of the JSONB blob and into standard TEXT or INTEGER columns. When a non-TOASTed column updates and the JSONB column remains untouched, Postgres reuses the existing TOAST pointer without rewriting the 80KB payload. Better yet, if no indexed columns change and space exists on the 8KB heap page, Postgres executes a Heap-Only Tuple (HOT) update—skipping index writes entirely.
  2. Switch to an append-only step ledger: Instead of concatenating tool outputs into a growing agent_runs.state array via state = state || $1::jsonb, create a narrow agent_run_steps child table (run_id UUID, step_index INT, output JSONB). Pure INSERT workloads generate zero dead tuples and zero TOAST churn.
  3. Tune autovacuum per table: For any table that must absorb high-frequency updates, override global defaults immediately: run ALTER TABLE agent_runs SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_limit = 2000, fillfactor = 80); so Postgres leaves 20% free space on every 8KB page for fast HOT updates and cleans dead tuples continuously.

What to Audit in Your Database Before Scaling Workers

Run SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC; against your production replica today. If your core workflow tables show hundreds of thousands of dead tuples or a pg_total_relation_size five times larger than your actual row count, your JSONB mutation pattern is bleeding disk throughput. For operations teams processing high-volume candidate resumes on Enorexa Recruiter or inspecting raw payloads with Enorexa Tools, keeping relational state lean is what keeps sub-10ms queries predictable under load. If your Postgres instance is hitting inexplicable IOPS ceilings or your AI background queues are stalling on database locks, explore our architecture work at Enorexa or talk to us to benchmark your schema.