Skip to main content

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.

Weight added at each pipeline stage Authored source grows through compilation, inlining and tracking rewrites, with inlining contributing the largest single increase. The Number You Can See Is Not the Number That Matters 102 KB authored 18 KB compiled 46 KB inlined 78 KB tracked 96 KB the jump is inlining
Measuring the authored source tells you almost nothing; the useful measurement is taken after the last stage that modifies the bytes.

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.

Per-template budgets versus one global limit A single global budget lets a small template double in size unnoticed, while per-template budgets catch the growth where it first appears. A Tight Budget Catches It Earlier password reset budget 30 KB — grew to 40, fails here notification budget 45 KB — comfortable order receipt budget 82 KB — the real risk One global 82 KB budget would pass all three, hiding the shared-layout change that caused the growth.
The small template is the useful sensor, because a systemic change shows there as a large proportional jump long before it clips anything.

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.

Size limits and how each one fails Gmail clipping is silent, gateway limits hard bounce, ESP payload limits fail at dispatch, and slow rendering is a user-experience cost. Four Limits, Four Failure Modes Gmail clipping, ~102 KB body Silent. Footer and pixel disappear; nothing in your logs changes. gateway limit, 10-25 MB message Hard bounce. Only reachable with attachments, and visible when hit. ESP payload limit, ~10 MB request Fails at dispatch, in your own logs, for every recipient at once. progressive rendering No hard limit — a visible delay on a slow connection, felt not measured.
The HTML budget targets the top-left quadrant precisely because it is the one that fails without telling anyone.

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.


← Back to Automated Snapshot Testing