Skip to main content

Revision history was capped at 20 per page. Why did that turn into 150 MB?

· 4 min read
Jon-Michael Lastimosa
Senior Web Developer

The question: Live Editor kept at most 20 revisions per page, which sounds like a bounded feature. One site was holding 150.6 MB of revision postmeta. Where did the bound go?

Context​

Each revision stores the full page-builder model as JSON in its own postmeta row. On a rich page that is around 1 MB. So "20 revisions" is really "up to 20 MB per page", and the cap was counting the wrong unit.

That alone would be a storage complaint. What made it urgent is how WordPress caches post meta: per post, not per key. The first get_post_meta() call for any key loads every row that post owns into the object cache. Measured on the worst page:

get_post_meta( 3603, '_wp_page_template' ) → 42 MB into memory

11.5 MB of stored rows expanding to roughly 42 MB of live PHP structures, triggered by an innocuous template lookup — on any request that touched that post at all. This turned out to be the root cause of a memory exhaustion we had chased weeks earlier and attributed to after_setup_theme, which was merely where the spike surfaced.

Options considered​

Lower the count. Twenty to five. Simple, and wrong in the same way as before: it still measures the wrong unit. A page with 3 MB revisions keeps 15 MB; a page with tiny ones loses history it could afford.

Cap by bytes only. Correct unit, but a page whose single revision exceeds the budget would keep no history at all — the feature silently stops working exactly where the pages are most valuable.

Cap by bytes, with a floor. Both caps, plus a minimum number of revisions that wins over the budget.

Move revisions out of postmeta. A custom table removes the meta-cache amplification entirely and is the architecturally cleaner answer. Much larger change, with a migration.

Decision​

Cap by bytes and count, with REVISIONS_MIN_KEEP = 2 beating the budget — and compress the payload.

Sizes are recorded in the revision index at write time, because the obvious implementation of a byte cap is to read each revision to measure it, and that would trigger the very meta-cache load the cap exists to prevent. Legacy entries are backfilled with a single LENGTH() query, so MySQL reports the size without sending the payload.

The custom table was deferred, not rejected. If the budget proves insufficient it is the next step.

Why​

The floor is the interesting part, because it deliberately breaks the guarantee the cap appears to make. A page can still exceed 3 MB if two revisions are large. That is accepted: history that always works is worth more than a budget that always holds, and the failure mode of "no undo on your most complex page" is worse than "this page uses 5 MB".

The part worth remembering​

Pruning alone barely helped. Shipping the byte cap first and measuring gave:

BeforeAfter pruningAfter compression
Revision postmeta150.6 MB115.4 MB8.68 MB
Worst page11.5 MB5.19 MB245 KB

Pruning removed 23%. The floor was binding on exactly the pages that mattered, which the code comment had predicted would need compression — so the prediction was written down before the measurement proved it, and then nearly ignored.

Compression did the real work: real builder JSON deflates about 27x, measured on actual rows rather than synthetic data. Builder JSON is extremely repetitive — the same option keys recur for every item — so this is not a general result to expect elsewhere.

Two implementation notes that were nearly bugs:

  • set_transient()-style thinking does not apply to update_post_meta, but the same trap appeared in the sibling rate-limiter work: an expiry of 0 means "never expires", not "leave it alone". Worth stating because it reads like a no-op.
  • The compressed marker (gz1:) was chosen to be something a JSON document can never begin with. That is what lets the reader accept old uncompressed revisions unchanged, with no migration flag and no version column.

Status​

Accepted, shipped in live-editor 0.2.57 with core 2.16.20. The measured install went from 150.6 MB to 8.68 MB, and the 42 MB meta-cache load to zero.