I've been making the images fast on a client's marketing site - Astro 7 and TinaCMS, deployed to Cloudflare Workers. It should have been a boring job: compress the files, set a long cache header, move on.
The cache header was already there. public/_headers gave /images/* a seven-day Cache-Control. The images were still slow.
The header was real. The images weren't ours.
TinaCloud rewrites every field declared type: 'image' into an https://assets.tina.io/<clientId>/... URL before the value ever reaches a template. The file is sitting in public/images, and the CMS stores a repo-relative path - but the content API rewrites it on the way out.
That host answers with:
cache-control: max-age=60So a news listing page with about 100 thumbnails on it was revalidating nearly all of them on nearly every visit. Our seven-day header was applying to almost nothing on the page.
Nothing was broken, which is why it survived so long. Every image loaded. They just loaded again, and again.
Why other Tina sites don't have this problem
Someone reasonably asked me why every Tina site isn't slow, and pointed at one that isn't. So I checked its config against mine.
It's identical. Same media.tina, same publicFolder: "public", same mediaRoot: "images". There was no wrong setting to find.
The difference is the framework. That site is Next.js, and every image on it is requested like this:
/_next/image?url=https://assets.tina.io/<clientId>/...&w=640&q=75next/image fetches the Tina URL server-side and re-serves the bytes from the site's own domain, under the site's own header - max-age=14400 in that case. The browser never talks to assets.tina.io for a content image at all. The problem is invisible because it's already been solved, by a component nobody chose for that reason.
Tina's own docs push you straight into this without saying why. The repo-based media page tells Next.js users to add assets.tina.io to remotePatterns - which is the line that makes next/image willing to proxy it. Follow the documented setup and you get the fix as a side effect. Caching is never mentioned.
I'm on Astro with imageService: 'passthrough', chosen deliberately because the deploy target has no free image optimiser. Passthrough means exactly what it says: the URL in your content is the URL the browser requests. Nothing stands between the CMS and the user.
So this isn't really a Tina bug, and it isn't a config mistake. It's a gap that opens when your framework doesn't happen to proxy images for you.
You can't turn the rewrite off from the config
This was the part that took the longest to accept. The resolver skips the rewrite only when useRelativeMedia is true, and that flag doesn't belong to the schema - it belongs to the GraphQL server:
- the local CLI hardcodes it true, which is exactly why local dev never shows you these URLs,
- and the cloud API sets it false whenever
media.tinais configured.
You can see it in the published types - the flag isn't part of your schema, it's part of the server's own config, and turning it off means handing over a client id and an assets host:
export type GraphQLConfig =
| { useRelativeMedia: true }
| { useRelativeMedia: false; clientId: string; assetsHost: string };So there's no line you can add to tina/config.ts to fix it. Dropping media.tina would give you relative paths back, but it also takes away the media manager the editors use to pick images. That's a bad trade to make for a cache header.
Which also explains why this is easy to miss: the environment where you do all your work is the one environment that doesn't have the problem.
The fix: normalise on the way out of the client
If I can't stop the rewrite, I can undo it. I wrapped the Tina client so every GraphQL result gets walked, and any assets.tina.io URL is rewritten back to the local path:
// The staging segment is only present when the editing branch differs from the
// media branch, so both shapes have to match.
const TINA_ASSET =
/^https:\/\/assets\.tina\.io\/[^/]+(?:\/__staging\/[^/]+\/__file)?(\/.+)$/;
function toLocalPath(url: string): string {
const match = TINA_ASSET.exec(url);
if (!match) return url;
// An image uploaded through the CMS but not yet pulled into the working tree
// exists on the CDN and nowhere else. Pointing that one at a local path turns
// a slow image into a missing image.
return existsSync(join("public/images", match[1])) ? `/images${match[1]}` : url;
}The existence check is the whole safety story. Without it you trade a caching problem for broken images, which is a much worse bug and one that only shows up on the images an editor added most recently.
Doing it on the client promise, rather than at each <img src>, matters more than it looks:
export async function request(...args) {
return rewriteTinaUrls(await client.request(...args));
}Image values don't only arrive in fields a template unpacks by hand. They turn up inside rich-text bodies, and inside list queries that get passed around whole. Fix it at the render site and you'll fix the images you happened to think of.
The bit I actually want to remember
My first attempt at this "worked". I tested it, a request to /images/... came back with the seven-day header, and I moved on.
That path was one image out of about eighty on the page. The other seventy-nine still resolved from the CDN.
Verifying a mechanism on a sample that isn't representative is worse than not verifying at all, because you walk away with confidence. The header existing was never the question. Where the images actually resolve from was the question, and I'd checked the easy one.
The smaller half: format
The image archive was mirrored from a decade-old WordPress install, so the JPEGs carried years of re-saves - each one a bit more compressed than the last, for no benefit. A conversion script took the referenced images from 22.2 MB to 10.5 MB, a 53% cut.
Three decisions in that script are worth stealing.
Never delete the originals. Legacy redirect rules resolve to the exact file the old CMS served, and an editor can still reference an image the script skipped. The originals cost repo size and nothing else.
Skip conversions that save less than about 15%. A near-identical duplicate costs repo size and adds one more file to keep in step, for no measurable gain. Thirteen photos got left alone on that rule.
Three files stay raster on purpose. Two of them are the ones that bite: the og:image, because Facebook and LinkedIn reject WebP for link previews, and the apple-touch-icon, because iOS ignores a WebP one. Worth knowing before a blanket conversion quietly kills your link previews.
On encoding: for a JPEG, re-encoding losslessly just preserves the compression artefacts it already has at great cost, so lossy is right. For a PNG - usually a logo, badge or diagram - lossless WebP often wins outright, but not for a photograph that happened to get saved as PNG. The script encodes both and keeps whichever is smaller.
A bonus CSS bug, same lesson
A full-width hero band was styled with Tailwind's aspect-[1920/620] plus max-h-[452px]. The intent: this ratio, but never taller than 452px.
aspect-ratio with a max-height doesn't just clamp the height. Once the cap binds, the browser derives the width from the ratio too. The band stopped growing at 1400px and left bare page either side of it on anything wider.
A viewport-relative height fixes it, because nothing is being derived from the ratio any more:
<div class="h-[min(32.3vw,452px)]">And the verification lesson repeated itself, in the same week. The check I ran measured the band's height and confirmed there was no horizontal overflow. Both passed. Neither of them measured the band's width, which was the only thing that had broken.
The takeaway
Two of these three bugs shipped past a check that passed. The check was real, the mechanism it measured was real, and it was pointed at the wrong thing.
Before you trust a performance fix, ask what the browser actually requested, on the page that was actually slow - not whether the thing you configured is configured.
