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

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:
- Promote mutable flags to native columns: Pull
status,current_step,retry_count, anderror_codeout of theJSONBblob and into standardTEXTorINTEGERcolumns. When a non-TOASTed column updates and theJSONBcolumn 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. - Switch to an append-only step ledger: Instead of concatenating tool outputs into a growing
agent_runs.statearray viastate = state || $1::jsonb, create a narrowagent_run_stepschild table (run_id UUID,step_index INT,output JSONB). PureINSERTworkloads generate zero dead tuples and zero TOAST churn. - 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.
Related Engineering & AI Articles
Cloud
DynamoDB Streams and Lambda: Killing the Poison Pill Loop That Freezes Shards
When an unhandled schema error hits an event-driven stream, your entire shard halts. Here is how we isolate poisoned payloads without dropping operational events.
November 7, 2026
Engineering
DynamoDB Single-Table Design: Why LLM Workflow State Breaks Partitions
Storing AI agent state in DynamoDB single-table schemas causes hot partition throttling and 400KB item limit traps. Here is how to isolate durable runs without operational fires.
November 6, 2026
AI
Why OCR Scrambles Multi-Page PDF Tables (And How Vision Pipelines Fix It)
Standard OCR engines flatten multi-column financial and logistics tables into garbled text streams. Here is how hybrid PDF bounding-box parsing and vision models stop silent number transposition.
November 5, 2026