| name | html-size-optimizer |
| description | Shrink first-byte HTML and embedded payloads: trim framework state scripts (Fresh/Next), event data attributes, JSON-LD bloat, and duplicate responsive markup so the client receives less markup and JSON. |
How to measure HTML size (do this first)
Before hunting for bloat, measure — and measure the decoded size, not the transferred one.
gzip/brotli compresses repeated JSON keys heavily, so a bloated page hides on the wire (a listing page
can be ~150 KB brotli but several MB decoded). The Network "transferred" column will look fine.
document.documentElement.outerHTML.length
curl -sS --compressed "https://<site>/<path>" | wc -c
Rule of thumb: a page over ~1–2 MB decoded is worth investigating — walk the sections below to find
what's inflating it. (In the browser Network tab you can also hover the "Size" cell → it shows
"X transferred over network, Y resource size"; the second number is the decoded size.)
1. JSON data for the framework
Many frameworks inject large JSON payloads into the HTML, for example:
<script id="__FRSH_STATE_">{}</script>
<script id="__NEXT_DATA__" type="application/json" crossorigin="">{}</script>
What to do: Reduce JavaScript sent to the client. Pass only the props that client components need.
2. JSON data for events
Sites sometimes store event-related data in the HTML:
<div data-event="%7B%7D"></div>
What to do: Send only the data that will actually be used.
The biggest real-world offender: per-card analytics events on PLPs
The most damaging version of this is an analytics event (GA4 select_item /
view_item_list, or a dataLayer.push) rendered once per product card that
serializes the entire product object into an inline <script>:
<script>
(function(){ window.dataLayer.push({ event: "select_item", ecommerce: { items: [
]}}); })();
</script>
The event only reads a handful of fields (item_id, item_name, item_brand,
price, item_category, index), but the full product graph rides along —
offers/installments alone can be hundreds of KB per card.
Why it costs money (egress + compute): this markup is regenerated by SSR on
every uncached render (and bots crawling facet combinations are almost always
uncached), so it burns CPU per render and egresses in full on every miss. On a
Deco/VTEX store this was measured at ~1 MB of HTML per card → an /joias/aneis
PLP of 11.9 MB decoded HTML (94% was this dead JSON).
What to do: build a minimal item with only the fields the event consumes and
pass that to the handler — never the raw product.
<SendSelectItemEvent product={product} />
<SendSelectItemEvent item={{
productID: product.productID,
inProductGroupWithID: product.inProductGroupWithID,
name: product.name,
brand: product.brand?.name,
url: product.url,
category: product.category,
}} />
Result on the store above: PLP HTML 11.9 MB → 0.63 MB (~18×), tracking
identical (same dataLayer event, field for field).
How to spot this specific case: after measuring the decoded size (see "How to measure" above),
grep for a marker that only exists in the serialized offers — on VTEX, billingIncrement:
(document.documentElement.outerHTML.match(/billingIncrement/g) || []).length
A nonzero count on a listing page means the full offers/installments object is being serialized per card.
3. JSON for SEO structured data
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": []
}</script>
What to do: Include only the structured data that is valid and used; avoid large or redundant objects.
4. Hidden elements for responsiveness
Sites sometimes hide whole blocks per breakpoint, doubling (or more) the HTML:
<header class="hidden md:block">a lot of HTML</header>
<header class="block md:hidden">a lot of HTML</header>
What to do: Serve only the HTML needed for the requesting device (e.g. split or choose on the server).