Gmail clips a message at 102KB, and our alert email runs out of room between 136 and 208 listings
Notifio watches rental search pages and emails you when a new listing appears. One email can carry listings from several searches at once, grouped by search, because the monitor walks every search in a cycle and the alert goes out at the end of it.
Gmail clips a message once it gets past about 102KB. You get the top of it, a [Message clipped] marker, and a "View entire message" link that opens the rest in a new tab. The figure is not contractual and I have never seen Google promise it, but it has been 102KB for years and it is the only number anyone has.
So: how many listings fit in our alert before Gmail cuts it off? I had never measured. The template is a string, so measuring it is just calling the function.
The document costs 1,656 bytes before any content
buildAlertEmail(alerts, totalCount) takes an array of searches, each with its new listings, and returns one HTML string. Calling it with nothing in it, then with one empty section, then with one empty row, isolates each fixed cost:
const empty = Buffer.byteLength(buildAlertEmail([], 0));
const section = Buffer.byteLength(buildAlertEmail([{ siteName: 'S', newListings: [] }], 0));
const oneRow = Buffer.byteLength(buildAlertEmail([{ siteName: 'S', newListings: [{ title: '', url: '' }] }], 1));
- document with no sections at all: 1656 bytes
- document with a section but no rows: 1893 bytes (+237 per search)
- document with one empty row: 2226 bytes (+333 per row)
Then the variable part. Growing the title by ten characters adds ten bytes. Growing the URL by ten characters adds twenty:
- cost of 10 title chars: 10 -> title counted 1 time
- cost of 10 url chars: 20 -> url counted 2 times
The URL is in there twice because the row renders it twice. Once as the destination, once as the visible grey line under the title so you can see where you are going:
<a href="${escHtml(l.url)}" style="color:#fafafa;font-weight:600;...">
${escHtml(l.title)}
</a><br>
<span style="font-size:12px;color:#525252;word-break:break-all;">
${escHtml(l.url)}
</span>
That #525252 span is the line I measured at 2.29:1 in yesterday's post about the contrast in these templates. It is the single most expensive element in the whole email per unit of usefulness: it doubles the byte cost of every row, and it is rendered in a grey you can barely read.
So a row is 333 + len(title) + 2 * len(url) bytes, and the whole document is:
1656 + 237 * searches + sum over listings of (333 + title + 2 * url)
Where the ceiling lands
Rental portals vary a lot in how long their titles and URLs are, so the answer is a range rather than a number. Binary-searching the function for the first listing count that exceeds 102KB:
| Title / URL length | Bytes per row | Clips at (1 search) | Clips at (15 searches) |
|---|---|---|---|
| 40 / 60 chars | 496 | 208 listings | 196 |
| 55 / 80 chars | 551 | 187 | 181 |
| 70 / 110 chars | 626 | 165 | 151 |
| 90 / 150 chars | 726 | 142 | 136 |
Between 136 and 208, depending entirely on how verbose the portal is.
The right-hand column is lower than the left because a user can watch up to fifteen searches and each one spends another 237 bytes on a section header, so the more searches contribute to one email, the fewer listings fit in it. Note which way that cuts. The email that is most likely to be big is the one covering the most searches, and that is also the one with the lowest ceiling.
What the reader actually loses
Clipping is not truncation with a warning in the right place. Gmail keeps the top and hides the tail, so what disappears is whatever the template emitted last, which is the last searches in the array.
The headline survives. It is built from a count the route computes before rendering:
const totalCount = alerts.reduce((n, a) => n + a.newListings.length, 0);
So a clipped email says "240 New Listings Found" at the top, in 22px bold, and then shows you about 180 of them. The subject line carries the same count and the same list of search names. Everything that tells you how much there is survives, and some of the content it is counting does not.
That is close to the worst available failure mode: the reader has no way to tell from the visible part that anything is missing except the clip marker, and the clip marker does not say how many.
The plain-text twin would be fine. buildAlertText is the same data with no markup:
- 3-listing email: 3549 B html vs 539 B text (6.6x)
- 250 listings: 139401 B html vs 35613 B text
At 250 listings the text version is still a third of the way to the limit. But sendEmail ships both parts and every client I care about picks the HTML one, so the twin that would survive is the one nobody sees. It also has no count in it at all, since buildAlertText(alerts) never receives totalCount, which in this one scenario makes it the more honest of the two.
41.5% of the message is inline style attributes
const styleBytes = (html.match(/style="[^"]*"/g) ?? []).reduce((a, s) => a + Buffer.byteLength(s), 0);
// 3-listing email: 3517 bytes total, 1460 bytes of style="" = 41.5%
This is not a mistake. An HTML email cannot use a stylesheet with any confidence, which is why these templates are nested tables with every declaration inline, and why one footer paragraph carries 60 characters of style for 12 characters of text. It is the correct way to build an email and it is also why the budget runs out at 180 listings instead of 2,000.
The compressible-looking repetition does not help either. Gmail measures the message, not the wire, so the fact that style="margin:0;font-size:12px;color:#525252;" appears identically in every single row and would gzip to nothing is irrelevant to whether you get clipped.
The ceiling that saves us is somewhere else
Here is the part I did not expect when I started measuring, and the reason this post is not a bug report. In normal operation a cycle turns up a handful of listings, not two hundred. To hit 180 in one email you need an unusual event: a search pointed at a very broad query on a busy portal, during a release window, across many searches at once.
And the monitor already refuses to report an implausibly large diff. There is a separate sanity guard, in a different file, that treats "almost everything on this page is new" as a sign that the comparison is wrong rather than that two hundred rooms appeared, and declines to alert. I wrote about the rule that the first check after the app starts never alerts for the same family of reasons.
So the practical ceiling on the email is enforced by a guard that exists to avoid sending you nonsense, and it happens to sit below the ceiling Gmail would enforce by mangling the message. That is luck. Nothing connects the two numbers. Nobody wrote "and this also keeps us under 102KB" anywhere, and if that guard were ever relaxed, or if somebody added a digest that batches several cycles into one email, the clip would arrive with no warning and look like a rendering bug rather than a size limit.
What I am going to do about it
Not a rewrite. Three small things:
- Assert the ceiling in a test.
buildAlertEmailis a pure function of an array. A test that builds 200 realistic rows and fails over 100KB is ten lines, needs no browser, and turns an accidental ceiling into a stated one. - Cap the rows and say so. If an alert would exceed the budget, render the first N and finish with "and 60 more, open the app to see them". The app has a local finds history for exactly this, so there is somewhere to send the reader.
- Drop the duplicated URL, or shorten it. The visible URL line doubles the cost of every row. Showing the hostname and path rather than the whole query string would cut row cost by a third and, as a side effect, fix the least legible text in the message.
The honest summary is that the limit was never a problem in production and was also never known. I prefer knowing. If you want to check the arithmetic, the template is four string literals and Buffer.byteLength does the rest.
Comments
No comments yet. Start the discussion.