Blog
@2x Is an Anti-Pattern
Somewhere out there, right now, as you read this, a developer is generating an 800px transform for a 400px card. They feel good about it. They have Done Retina. In a moment they will tell their project manager that the images are retina-ready, and the project manager will nod approvingly, because everyone knows retina means double the pixels.
I'd like to gently ruin this for them.
The ritual goes like this. A designer hands over a spec, the card image is 400px wide. The developer dutifully generates a 400px transform and an 800px transform, wires up a srcset with 1x and 2x descriptors, ticks the retina box, and moves on to a problem that's actually hard.
It's a perfectly good workflow. It's just a workflow from a different platform. On the responsive web it's usually wrong, and it's wrong in the most irritating way possible, which is that everything still looks great on the machine you built it on.
Where this comes from, and why it isn't a silly idea
@1x, @2x, @3x is iOS vocabulary, and in iOS it's a genuinely lovely system. A native UI is laid out in points. A button is 44 points tall on every iPhone ever made. The only thing that varies is how many device pixels the screen crams into a point. So you ship three versions of the asset, the OS picks the right one, and you go to lunch.
Then Sketch and Figma put the same vocabulary in the export dialog, several million people clicked it, and "retina means double the pixels" quietly graduated from platform detail to law of nature.
The whole arrangement rests on one assumption, though. The element has a known, fixed size. Pull that out and the tower comes down.
Your image doesn't have a size
This is the bit worth sitting with for a second.
That card image isn't 400px. It's 400px in one breakpoint, on a desktop, when the stars align. On mobile it's probably full-bleed. In a three-column grid it's some fraction of the viewport. On a 27" monitor it's maybe 520px, because the container has a max-width and the grid gaps scale and nobody has thought about this since the design was approved.
So what's the @2x version of an image that renders anywhere between 320 and 520 CSS pixels depending on the mood of the viewport?
Trick question. There isn't one. A 1600px JPEG isn't the 2x version of anything. It's 1600 pixels. Whether that lands as 1x, 2x or 3.7x depends on how wide the image actually renders and what DPR the screen has, and you know neither of those when you're writing the template.
Which gets us to the thing I actually want to say:
A width-based srcset already contains its own retina versions.
Serve 400, 800, 1200, 1600 and 2000, and that 800 is the 1x source for an 800px slot, the 2x source for a 400px slot, and the 3x source for a 267px slot. One file, three jobs, no overtime. You never had to think about DPR at all. You described the pixels you have, the browser knows the layout width and the pixel ratio, and it does the multiplication itself. It's quite good at multiplication.
That's the entire design of w descriptors plus sizes. Strictly more expressive than x descriptors, and high-DPR support falls out for free.
Four ways the @2x habit bites you
Roughly in order of how much it costs.
1. x descriptors throw the layout width in the bin.
With x descriptors, the browser picks on device pixel ratio and nothing else. It has no idea how wide the image is going to render, and sizes is ignored completely. For a fixed-size element that's fine, since there's nothing to know. For a fluid one, you've just deleted the bigger of the two variables.
Here's what that looks like in practice. You ship card-400.jpg 1x, card-800.jpg 2x for a card that's 400px on desktop. Someone opens the page on a phone with a 430px viewport at DPR 3, where the card goes full-bleed. That slot wants 1290 device pixels. The browser reaches for the 800 and upscales it. You did retina, and the sharpest screen in the building got the blurriest card on the page.
2. You end up generating the same files twice.
Once you're thinking in 1x/2x pairs, the obvious move is to double every design size. Start from 400, 800, 1200, 1600, double them all, and now you're also producing 800, 1600, 2400 and 3200. You may spot some familiar faces in that list. Twice the transforms, twice the storage, twice the CDN cache, for approximately nothing.
3. DPR is not 2.
It hasn't been 2 for a very long time. Android has been happily shipping 1.5, 2.625, 3.5 and 4 for years. Someone out there is browsing at 2.75 right now and feels no guilt about it. Browser zoom shifts the number while the page is open. A 2x descriptor is a confident guess about hardware that stopped being a safe guess around 2013.
4. It eats the attention that sizes deserves.
This is the expensive one. Teams will burn an afternoon on their retina strategy and then ship sizes="100vw" on an image that renders at a third of the viewport, because 100vw is what you get if you don't think about it and nobody thought about it. That single attribute wastes more bandwidth than every @2x decision on the site put together, and fixing it takes about eleven seconds.
If you only have so much attention to spend on responsive images, spend it here. Get sizes right and DPR quietly sorts itself out.
The free lunch nobody orders
There's a nice bonus that falls out of thinking in widths instead of multipliers.
When an image is going to be downscaled by the browser, you can compress it far harder than feels reasonable and get away with it. Downscaling averages away most of the artifacts on the way down. A 2000px JPEG at quality 55 will often be both smaller on the wire and better looking in a 1000px slot than a 1000px JPEG at quality 80.
The @2x framing makes this hard to see, because you're mentally shipping "the same image, but bigger", and deliberately degrading it feels like vandalism. A width ladder makes it obvious. The big files exist to be shrunk, so let the quality drop as the width climbs.
{% set images = craft.imagerx.transformImage(image, [
{ width: 600, jpegQuality: 80 },
{ width: 1200, jpegQuality: 65 },
{ width: 2000, jpegQuality: 55 }
], { ratio: 16/9 }) %}
One caveat before anyone goes wild. Large images cost decode time and memory on cheap phones no matter how small the file is, so this isn't a licence to ship a 4000px hero to a watch. Within sane limits, though, the quality-per-byte curve rewards you for going wide and cheap.
What to write instead
Nothing exotic. Widths, sizes, and then get out of the browser's way.
{% set images = craft.imagerx.transformImage(entry.image.one(), [
{ width: 400 },
{ width: 2000 }
], { ratio: 16/9 }, { fillTransforms: true, fillInterval: 200 }) %}
<img src="{{ (images|last).url }}"
srcset="{{ images|srcset }}"
sizes="(min-width: 1200px) 380px, (min-width: 700px) 33vw, 100vw"
width="{{ (images|last).width }}"
height="{{ (images|last).height }}"
alt="{{ entry.image.one().title }}">
fillTransforms fills in the intermediate steps, so you only declare the two ends of the range. Pick the top end by asking what the widest realistic slot is at the highest realistic DPR, and then stop. If the image never renders wider than 500 CSS pixels, 2000 is generous and 3000 is showing off.
Notice what isn't in there. No retina. No doubled transforms. No arithmetic. That's rather the point.
You might also have noticed that Imager X's srcset filter only offers w, h and w+h. There's no x option. That was a choice, and this post is the long-winded version of it.
In defence of @2x
Right, the part where I walk it back, because the exception is real and I don't want to send anyone off to fight the wrong battle.
x descriptors are correct, and honestly nicer than the alternative, when the element has a fixed CSS size that stays fixed. Not mostly fixed. Not fixed on desktop. Fixed.
That covers a decent chunk of real interface:
- Logos in a header
- Icons that for whatever reason didn't get to be SVG
- Avatars, which are a hardcoded square roughly 100% of the time
- Payment badges, app store buttons, and similar chrome
- Map pins and markers
- Thumbnails in a list with a fixed row height
Here w + sizes still works, it's just needlessly chatty. You'd be typing sizes="180px" to tell the browser something your stylesheet already told it. Use the multiplier and move on:
{% set logo = craft.imagerx.transformImage(entry.logo.one(), [
{ width: 180 },
{ width: 360 },
{ width: 540 }
]) %}
<img src="{{ logo[0].url }}"
srcset="{{ logo[0].url }} 1x, {{ logo[1].url }} 2x, {{ logo[2].url }} 3x"
width="180" height="60"
alt="Acme Corp">
Since Imager's srcset filter won't emit x descriptors, you write that one out by hand. It's three lines. You'll live.
Two things worth watching:
If the size changes at a breakpoint, it isn't fixed. A logo that's 180px on desktop and 120px on mobile is two fixed sizes wearing a trenchcoat. Either go back to w + sizes, or use <picture> with media queries and a separate x srcset per source. More markup, but at least it's telling the truth.
You can't mix descriptor types. The spec won't let you put w and x in the same srcset. One or the other, per source.
The one question
Before you write a srcset, ask yourself: does this image render at exactly one CSS width, always?
If yes, use x descriptors, ship 1x/2x/3x, and get on with your life.
If no, and it's almost always no, use w descriptors and put your effort into sizes instead. Retina isn't a feature you bolt on at the end. It falls out for free the moment you describe your images honestly and tell the browser how much room they're getting.
And if you've been doing the @2x thing all along, don't lose any sleep over it. It's a good idea that wandered onto the wrong platform and got an extremely warm welcome. Easy mistake to make, easy enough to stop making.