Skip to main content

The options modal shipped 6.7 MB — ship schemas instead of markup?

· 5 min read
Jon-Michael Lastimosa
Senior Web Developer

The question: Every element's options modal took ~873 ms and ~6.7 MB to open. An existing "lazy multi-picker" optimisation was already in place and clearly not working. What is actually costing that, and can it be deferred without risking saved values?

Context

Profiling one accordion modal, tab by tab:

TabTimePayload
Content / Layout / Behaviour / Advanced~29 ms~138 KB
Styling58 ms282 KB
Animations842 ms6,261 KB

The Animations tab holds 16 module cards, each a multi-picker. A multi-picker renders the picker plus every choice's sub-options, showing one and hiding the rest. The Entrance picker alone renders a settings block for all ~56 Animate.css effects. One card measured 703 KB containing 205 option-type instances.

The existing optimisation turned out to be doing nothing useful. It rendered every choice server-side and moved the result into a data-options-template attribute — sparing the browser some DOM nodes while transferring the same bytes (slightly more, once HTML-escaped) and spending the same render time. A mechanism named "lazy" that deferred nothing.

Critically, multi-picker::_get_value_from_input() already persisted only the selected choice's sub-values. So ~95% of that payload was markup the save path could never read.

Options considered

  • Leave it. It works, and the cost is invisible until you profile. But it is ~6 MB per modal open for every user of every element, forever.
  • Rewrite the editing layer (a React canvas, client-side option state). Fixes it eventually, but it is a multi-year rewrite for a problem that turned out to be one control misbehaving.
  • Defer unselected choices — ship their schema, render on demand when picked.

Decision

Defer them, behind an opt-in lazy_choices flag. Unselected choices ship their option schema as JSON instead of rendered HTML; when a choice is picked, the client renders it through the existing fw_backend_options_render endpoint and injects the result.

BeforeAfter
One animation card703 KB151 KB
Animations tab6,261 KB · 842 ms1,679 KB · 60 ms
Whole modal6,680 KB · 873 ms2,098 KB · 137 ms

Opt-in rather than default: most multi-pickers are small enough not to care, and an opt-in keeps the blast radius at the one place with the problem.

The safety rule — the part worth remembering

This exact idea had been tried before at a different level and lost data: whole animation cards were deferred, and a field absent from the form makes the server derive its default, so newly-added animations silently saved as none. The code carried a comment warning against retrying it.

The distinction that makes deferral safe is one level down:

Defer only what the save path never reads. If _get_value_from_input() collects just the active variant, the inactive variants' inputs are already ignored — so withholding them changes nothing. Never defer the selector, or a whole field: those are read, and their absence silently becomes a default.

Under that rule the picker is always rendered (so its value is always in the form), and the selected choice's group is always present — initially, or fetched before the user can save. Two supporting details: a save gate (the modal waits on in-flight renders before serializing, so a fast Save after switching choice cannot serialize an incomplete form), and the schema attribute is claimed before fetching and restored on failure, so a choice can retry rather than end up with no inputs.

Why

  • The architecture was never the problem. The same server-rendered options model went 6× faster by not rendering things nobody asked for. Editing latency had been filed as a structural weakness of the framework; it was a bug in one control.
  • The rule generalises. Auditing all 75 elements with the same method found the pattern again in the table option type, and the rule resolved it the same way — see below.
  • It is measurable, so it can be governed. A healthy element modal is ~2 MB / ~130 ms. That number is now in the option-type and shortcode authoring guides with a snippet to check it, so a new element or animation module that regresses it is caught by its author rather than by users.

Addendum: the same rule, a differently-shaped case

Auditing all 75 elements found one outlier left — table, at 16,435 KB / 532 ms against a ~2 MB median. The cause was the legacy pricing editor, which renders a full option set per cell, per row type (~11 MB) and was rendered on every table modal, including tabular tables (the default).

The rule applied unchanged: for a tabular table that markup isn't merely hidden, it's unreadable_get_value_from_input() returns get_value_from_json() unless the purpose is pricing, so those inputs are never collected.

Two things differed from the multi-picker case, and they're the reason this is worth recording separately:

  • The unit deferred is a whole editor, not a variant within a control. That sounds like the forbidden "defer a whole field" — but it isn't, because the selector here is the purpose dropdown, which is always rendered. The boundary is about what the save path reads, not about how big the deferred thing is.
  • Nothing extra is shipped to enable it. The multi-picker inlines each unselected choice's schema (~5% of its HTML). Here even that was avoidable: the element declares only type/label/desc/help on its table option, so the whole ~600 KB schema is rebuilt server-side by replace_with_defaults(). Only the option's id, input name and current value travel with the page.
BeforeAfter
Tabular table modal16,435 KB · 532 ms3,227 KB · 335 ms

A pricing table still renders its editor eagerly; switching the purpose fetches it on demand, gated by the same fw.lazyChoices.pending check so a fast Save can't serialize a form without it.

With that, no element is an outlier: the whole library sits in a ~2–3 MB / ~130–335 ms band.

Status: Accepted, shipped in core 2.16.12, Shortcodes 1.13.35 (multi-picker) and 1.13.36 (table).