All notes

20 September 2026 · 9 min read

The ad you cannot change after you send it

An image URL inside a delivered email is immutable, and Google caches it. Both facts are fixed. The ad still had to change on every open.

An advertisement inside an email is an image, and an image is a URL. Once that email is delivered the URL is permanent — it sits in an inbox you have no access to, in a message you cannot edit, possibly for years.

That alone is survivable. You control what the URL returns, so you can decide at fetch time. What makes it hard is the second fact: Gmail does not let the subscriber fetch your URL. It fetches on their behalf, through its own image proxy, stores the result, and serves every subsequent view from that store.

So the ad is decided once, at the first fetch, and then frozen. Every consequence of that is commercially unacceptable.

What being frozen actually costs

  • Repeat opens are invisible. The image is fetched once; every later open is served from a cache you cannot see, so the second and tenth views generate nothing.
  • Creative cannot rotate. Whichever advertiser won the first fetch owns that slot permanently, in that copy of that message.
  • A finished campaign keeps running. Budget exhausted, flight ended, advertiser churned — the cached image carries on being displayed.
  • Every decision your ad server can make becomes a decision it made once, months ago, about a context that no longer exists.

The three obvious fixes, and why each one fails

The instinct is to make the URL unique per request — a cache-busting parameter. That cannot work here, because the URL was written into the message before it was sent. There is no request to vary on. The message is already in the inbox.

The second instinct is to set a short cache lifetime on the response. This fails for a subtler reason, and it is the one that sends people in circles: the browser never fetches your URL at all. It fetches the proxy's URL. The proxy decides, using its own policy, how long to keep your bytes. Any lifetime you set greater than zero is an invitation for the proxy to cache the mapping and stop asking you.

The third instinct is to redirect to a content delivery network, which is what you would do for any other image on the internet. It fails for the same reason one layer down: if the final hop is a redirect to a CDN, the proxy caches the CDN URL along with the CDN's cache headers, and you have handed away the only decision point you had.

And one worth naming because it is the most natural reach of all: a permanent redirect. A 301 is the most aggressively cached response in the protocol. Using it here would not merely fail, it would fail permanently and in a way that is very difficult to undo.

Stop trying to change the URL. Change what it means.

The URL in the message never changes and never needs to. What changes is its status: it stops being the address of a picture and becomes a question, asked fresh every time anyone looks.

Three hops, and every one of them uncacheable by construction.

Hop 1  /display       302  Cache-Control: no-store, no-cache
       → mints a fresh token, runs the waterfall now

Hop 2  /display-img/{token}  302  no-store, no-cache
       → resolves the winning creative for THIS decision

Hop 3  the bytes      200  no-store, no-cache
       → proxied through the ad server, never a redirect to a CDN

The first hop is the one that matters, and it is the one that looks wrong. It refuses to be stored at all, which means the proxy has to come back to us on every single open. Every one of those returns a newly minted token, and a token is not a picture — it is the identity of a decision made at that instant, against the current budgets, the current flight dates and the current waterfall.

The last hop is the one people get wrong. It has to return the actual bytes with a status of two hundred, proxied through the ad server rather than redirected to storage. The moment that hop becomes a redirect, the proxy caches the destination and the whole chain is decorative.

What the subscriber sees is an image that loads normally. What is actually happening is a full auction, on their open, in the time it takes a picture to appear.

The second problem hiding inside the first

Gmail fetches the images in a message before the subscriber has opened it, as part of scanning for spam. That scan looks exactly like an open, and it happens once per delivery.

Both simple responses are wrong. Block the scanner and you take a delivery penalty, which is the most expensive thing that can happen to a sender. Treat it as an open and every message reports a phantom impression, every campaign burns budget on nobody, and the reporting a customer sees is inflated by exactly one view per recipient.

The resolution is to do both things at once, which is only available if you can tell the two apart: serve the advertisement so the scanner sees a well-behaved image, and record nothing — no impression, no budget deduction. Identification comes from three signals rather than one, because no single one is reliable: the exact scanner user agent, a referrer on the proxy's own domains, and newer builds that identify themselves by name.

The third problem, which is unfixable and must be designed around

Because the proxy fetches on the subscriber's behalf, the address making the request belongs to Google. Not to the person reading. So every piece of location targeting that depends on the requesting address is, for a very large share of all recipients, describing a data centre.

There is no clever fix for this one. There is only an honest design: know when you are being proxied, do not pretend the location signal is trustworthy in that case, and fail open. If the detection is ambiguous, serve the advertisement rather than block it — an unserved impression is a certain loss, while a mistargeted one is merely a worse outcome.

One last piece, because parallelism is where this gets fiddly

Clients fetch the pieces of a message in parallel, so a second request arrives asking whether the first one found anything to serve — before the first has finished deciding. The naive implementations are a sleep or a poll, and both spend a request thread waiting.

It is resolved with a signal written once the decision is made and a deferred response on the other side, so the waiting request holds no thread at all, with a two-second timeout as the outer net. That detail is invisible to everyone and it is the difference between a server that handles a send of a million and one that falls over on it.

Manmohan Singh, headshot

Written by Manmohan Singh, who builds the systems that move the money. About.

The build log, by mail

New notes, when I publish them.

One mail per note: what broke, the theory I held before I understood it, and the rule I kept afterwards. Irregular on purpose — I write these when something happens, not to a schedule, and I would rather send you nothing than send you filler.

Your address, nothing else. Not sold, not shared, and never used to sell you anything you did not ask about. Reply “stop” to any of them and I take you off.

If this sounds like your stack, say so.

A teardown is five days and a fixed fee, and you keep the findings whether or not we go further.

Get in touch