Catching HTML Size Regressions Before Gmail Clips the Message
Gate on message weight: where the 102KB Gmail clipping threshold applies, measuring after inlining, per-template budgets, and attributing a size jump to its cause.
Gmail truncates a message body past roughly 102KB and replaces the remainder with a "View entire message" link. Everything after the cut — including the unsubscribe footer and often the tracking pixel — is invisible to most recipients. This guide covers measuring the right number, budgeting it per template, and attributing a jump to whatever caused it.
Root Cause: The Weight Is Created Downstream of the Author
A developer looks at a template that is 18KB of source and reasonably concludes size is not a concern. The message that reaches Gmail is four to six times larger, and every stage of that growth happens after the file they were looking at.
Compilation expands components into nested tables with attributes on every element. A single MJML section becomes several tables, rows and cells, each carrying inline styles the author never typed.
Inlining is the largest jump. Every rule in the style block is written onto every element it matches, so a rule matching forty cells is duplicated forty times. A stylesheet that is 4KB as a block can add 30KB as inline attributes.
Tracking rewrites add the final layer. Click tracking replaces every href with a longer redirect URL carrying an encoded payload, and a message with thirty links can gain several kilobytes from that alone.
None of that is visible in the repository, which is why size regressions are almost always discovered by a recipient rather than by a developer.
The Exact Check
Measure the fully composed message, after inlining and after any tracking rewrite your pipeline applies, and compare it against a budget well below the threshold.
// size.test.mjs — assert the shipped weight, not the authored weight.
import { renderTemplate } from "./render.mjs";
import { inlineCss } from "./inline.mjs";
import { applyTracking } from "./tracking.mjs";
// Gmail clips around 102KB. Budget at 80% so a small change never ships a
// clipped message, and so there is room for longer recipient data.
const BUDGET_BYTES = 82_000;
// Fixture data at the LARGEST realistic size: the longest name, the most line
// items, the longest product titles. Average data hides the failure.
const WORST_CASE = {
customer: { name: "Wolfeschlegelsteinhausenbergerdorff", email: "a".repeat(60) + "@example.com" },
order: { id: "ORD-2026-08-14-000042", items: Array.from({ length: 25 }, (_, i) => ({
name: `Product with a deliberately long descriptive title ${i}`,
qty: 1, price: "£19.99",
})) },
};
test("shipped message stays under the size budget", async () => {
const { html } = await renderTemplate("order-receipt", WORST_CASE);
const shipped = applyTracking(await inlineCss(html));
// Byte length, not string length — multi-byte characters count as they are
// transmitted, and a template with currency symbols is not ASCII.
const bytes = Buffer.byteLength(shipped, "utf8");
expect(bytes).toBeLessThan(BUDGET_BYTES);
});
Two details carry the value. Measuring bytes rather than characters matters because a template containing currency symbols, accented names or emoji transmits more bytes than its character count suggests. And using worst-case fixture data is what makes the check meaningful: a receipt with two line items passes at any budget, and the message that clips in production is the one with twenty-five.
Setting a Budget Per Template
A single global budget is easier to configure and worse at its job, because templates have genuinely different weight profiles. A password reset with one link and forty words has no realistic path to 80KB; an order receipt with a variable-length item list does.
| Template type | Suggested budget | Why |
|---|---|---|
| Verification code, password reset | 30 KB | Fixed content, no lists, few links |
| Simple notification | 45 KB | One or two links, short body |
| Order receipt with line items | 82 KB | Variable-length list is the risk |
| Digest or summary | 82 KB | Many links, many rows |
Setting the tight budgets deliberately is what makes the check useful rather than decorative. A password reset that grows from 22KB to 40KB is nowhere near clipping, but the growth is a signal — usually that a shared layout picked up a large block of CSS, which will matter much more on the receipt template that shares it. The tight budget catches the systemic change on the page where it is cheapest to notice.
Attributing a Jump
When the budget fails, the question is which stage added the weight. Reporting the size at each stage rather than only the final number answers it directly.
A jump between authored and compiled points at the template structure — usually a new nested component or a repeated block. A jump between compiled and inlined points at CSS: a broad selector now matching many more elements, which is the classic cause and often comes from a rule added to a shared layout. A jump between inlined and tracked points at links, meaning either more of them or a longer tracking payload.
Printing all four numbers on failure turns a size regression from an investigation into a reading. Where the pipeline supports it, printing the top few CSS selectors by inlined byte count narrows it further — a single rule accounting for 12KB is immediately obvious and rarely intentional.
Reducing Weight When the Budget Fails
Four techniques, in rough order of return.
Narrow broad selectors. A rule matching td writes itself onto every cell in the document. Scoping it to a class reduces the matched set by an order of magnitude and typically recovers more bytes than everything else combined.
Move invariant styles out of the inliner. Properties that never differ per element — a font family repeated on 200 cells — can be declared once in the style block for the clients that read it and inlined only where it matters. This trades some robustness for size and should be applied selectively.
Remove unused CSS before inlining. A shared layout usually carries rules for components a given template does not use. Running a purge step against the compiled HTML before inlining removes them and costs nothing in fidelity.
Shorten link payloads. Tracking URLs carrying an encoded JSON blob are frequently far longer than they need to be; a short opaque identifier resolved server-side is smaller and also less revealing.
Where the Threshold Bites Beyond Gmail
Gmail's clipping is the best-known limit and not the only one, and a message sized comfortably for Gmail can still meet a wall elsewhere.
Corporate gateways frequently impose a message-size limit measured across the whole MIME message including attachments, commonly in the 10–25MB range. That is far above anything an HTML body reaches on its own, but a receipt with an attached PDF invoice can approach it, and the rejection is a hard bounce rather than a truncation — which means the recipient gets nothing at all.
Mobile clients on slow connections do not truncate, but they do render progressively, and a very large body produces a visible delay before the message appears. That is a user-experience cost rather than a correctness one, and it argues for the same budget from a different direction.
Your own ESP may impose a payload limit on the send API, typically around 10MB for the request body including any inline attachment. A template that grew past it fails at dispatch rather than at delivery, which at least surfaces in your own logs — but it fails for every recipient at once.
The practical consequence is that the HTML budget protects against the tightest and most silent limit, and the others need separate attention. Where a message carries attachments, measure the composed MIME message as well as the body, and set that budget against the smallest gateway limit you know your recipients use rather than against the largest.
Validation and Deployment Checklist
Frequently Asked Questions
Is the 102KB threshold exact?
It is approximate and undocumented, and it applies to the message body rather than to the full MIME message including headers and any attachment. Treating it as exact is unwise, which is precisely why the budget sits well below it — the margin absorbs both the imprecision and any recipient data longer than your fixture.
What actually happens when a message is clipped?
Gmail shows the content up to the cut, then a link to view the rest in a browser window. Everything after the cut is not rendered: the footer, the unsubscribe link, and any tracking pixel placed at the end. That last one means open tracking silently stops working for clipped messages, which is a common and confusing analytics artefact.
Should the tracking pixel move to the top of the message?
It is a reasonable defensive measure, and several senders do it. It does not fix the underlying problem — a clipped message still hides the footer, which for a bulk sender is a compliance issue — but it does stop a size regression from also corrupting your open-rate data while you fix it.
Does image weight count toward the limit?
No. The threshold applies to the HTML body, and images are referenced rather than embedded, so a message with twenty large images can be well under the limit. What does count is the markup around them — an image gallery with a table cell, inline styles and a tracking-rewritten link per image contributes meaningfully even though the pictures themselves do not.
Related
- Automated Snapshot Testing — the suite this assertion joins
- Inline CSS Automation — the stage that adds most of the weight
- Spam Filter and Inbox Placement Testing — the other build gate applied to the composed message
- Reducing Spam Score in Image-Heavy Emails — why image count still matters despite not counting toward the limit
← Back to Automated Snapshot Testing