Two people look at the same page on two machines, and the logo in the header is not quite the same. On one screen the strokes look thinner and closer to the original artwork. On the other they look heavier.

Nothing in the CSS explains it. The image file is identical, the dimensions are identical, the device pixel ratio is the same. The difference is the browser, and it is not a bug. It is a decoding optimisation that has been in the stack for years, doing exactly what it was designed to do.

The short version, for anyone who wants the answer without the mechanism: a JPEG shown at a small fraction of its original size can be decoded at reduced scale rather than decompressed in full, and at heavy reductions the data that gets skipped is the detail that gives a fine stroke its weight. Use a vector for marks and the problem does not arise.

This one is worth understanding if you ship a design system, because it explains a class of visual difference that is invisible to every tool you would normally reach for, and it points at a rule about asset formats that most style guides state without saying why.

The credit here goes to a writeup by Guillaume, who chased this after noticing a 15px logo rendering differently on a colleague’s machine. What follows is his explanation applied to design system work, including a correction he added after publishing.

Scaling down is more expensive than it looks

Start with the naive way to render a small image from a large JPEG. Decompress the whole thing into memory, then scale the result down to the size you need.

Take the numbers from his example. A 2000 by 2000 JPEG displayed at 20 by 20. Fully uncompressed, the bitmap is roughly 12MB. The final image needs about 1.2KB.

So the browser would be expanding twelve megabytes of pixel data in order to throw almost all of it away. On a page with a row of partner logos, or a comment thread full of avatars, that arithmetic repeats per image.

What actually gets thrown away

The useful insight is that the information lost in heavy downscaling is not random. It is mostly high-frequency detail.

His illustration is a tree. Leaves and rough bark are fine details that change quickly from pixel to pixel, which makes them high frequency. Scale that tree down to 20 by 10 and you get a green blob on top of a brown stick. The fine detail is gone. Some of it survives in mixed form, but the character of the small version is carried by the broad shapes.

If the high-frequency information is going to be discarded anyway, decoding it first is wasted work. That observation is the whole optimisation.

How JPEG stores an image

Enough of the format to follow the argument, and no more.

During compression, a JPEG is split into 8 by 8 blocks, and each block is converted into the frequency domain by a Discrete Cosine Transform, the DCT.

Inside one of those blocks, the lowest possible frequency is a flat colour. Strictly it is not a frequency at all, because nothing changes across the block. It is the constant component. At the other extreme sits a checkerboard, where the value changes as much as it possibly can from one pixel to the next. Everything between those two is the rest of the range, and the set of patterns is called the basis functions.

Converting a block to the frequency domain is asking one question of each pattern: how much of this is present here? The answers are the coefficients. What JPEG stores, after some further steps that do not matter here, is those coefficients.

A JPEG does not store pixels. It stores how much of each pattern is present in every 8 by 8 block.

The optimisation: partial IDCT scaling

Now put the two halves together. Suppose you want the image at one eighth of its size. Each 8 by 8 block becomes a single pixel.

At that size the result mostly needs low-frequency information, because the fine detail was going to vanish regardless. So rather than decompressing everything, a decoder can skip the coefficients describing the high-frequency patterns and reconstruct from the coarse ones alone.

The result is a scaled-down image produced without ever expanding the original, using less memory and less time. The technique is called partial IDCT scaling, the inverse discrete cosine transform being the trip back from the frequency domain to actual pixels. It works for any fraction with a denominator of 8, and the same idea can be used to scale up.

This is a genuinely clever piece of engineering. It is worth saying plainly before the next section, because what follows can read as a complaint and is not one.

Where Chrome fits

Chrome hands image decoding and rendering to Skia. For JPEGs, Skia uses libjpeg-turbo, which implements partial IDCT scaling.

So Chrome does not always decompress fully and then scale. It works out the closest fraction with a denominator of 8, decodes at that scale, and then scales the rest of the way with a more conventional downsampling algorithm.

Which explains the logo. Rendered small enough, it was decoded at one eighth scale. At that point the surviving data from the frequency representation was essentially the constant component, so the edge softening and the gradients that make a stroke look its intended weight were never reconstructed. What reached the screen was blockier, and blockier reads as heavier.

The correction, which matters

On 12 August the author added a correction to his own post. Someone on Hacker News pointed out that the downsampling algorithm applied afterwards plays a significant role in the final appearance, so the degradation is a mix of the partial IDCT step and the scaling algorithm rather than the IDCT alone.

That is worth repeating rather than quietly dropping, for two reasons. It is the accurate version. And it changes the takeaway slightly: this is not one identifiable feature you can point at and route around. It is the combined behaviour of a decode path, which is exactly why the difference is hard to chase from the outside.

Why a design system feels this more than a blog does

An article photograph rendered at 800px wide will not go anywhere near this path, and if it did, nobody would notice. Photographs are what JPEG was built for.

Design systems are full of the opposite kind of asset. Small, high-contrast marks with fine strokes, displayed at fixed small sizes, often from a source file considerably larger than the display size.

  • A wordmark in a site header at 24 or 32 pixels tall.
  • Avatars in a comment thread or a member directory.
  • A row of client or partner logos, each supplied by someone else at whatever size they had.
  • Payment method marks in a checkout footer.
  • App store badges, certification marks, membership badges.
  • Category or author thumbnails in a card grid.

Every item on that list is a small raster with thin strokes and hard edges. Every one is a candidate for exactly this.

And the failure is peculiarly hard to file as a bug. It does not reproduce for the person reporting it if they are on a different browser. It cannot be found in the stylesheet. It survives a cache clear. The usual outcome is that a designer insists the logo looks wrong, a developer cannot see a problem, and everyone quietly agrees to move on.

The rules that follow

Four, in order of how much they will save you.

1. Do not use JPEG for marks

This is the author’s own conclusion and it is the right one. JPEG and its optimisations are designed around how we perceive photographs. The clue is in the name: Joint Photographic Experts Group.

A logo is not a photograph. It is flat colour and sharp edges, which is the content type that JPEG handles worst and that its optimisations are least suited to. Use SVG where you have vector artwork, and PNG or WebP where you do not.

<!-- Fine strokes at a small size, from a photographic format. -->
<img src="/logo.jpg" width="32" height="32" alt="Acme">

<!-- Same mark, resolution independent, no decode path to worry about. -->
<img src="/logo.svg" width="32" height="32" alt="Acme">

His own fix was to swap the raster for an SVG, and the difference went away.

2. Serve raster assets near their display size

Where a raster is unavoidable, the further the source is from the rendered size, the more aggressive the decode path becomes. A 2000px source shown at 32px is asking for a heavily reduced decode. A 64px source shown at 32px is not.

<img
  src="/avatar-96.webp"
  srcset="/avatar-48.webp 48w, /avatar-96.webp 96w, /avatar-144.webp 144w"
  sizes="48px"
  width="48" height="48" alt="">

This is the same discipline you already apply for bandwidth. It turns out to buy fidelity as well.

3. Distrust one asset reused at many sizes

The common shortcut in a component library is a single uploaded image referenced everywhere, resized by CSS at each call site. It is convenient and it is the setup most likely to produce this.

The same file will look correct in a hero and subtly wrong in a 24px slot, and the component will get blamed for something the asset pipeline caused.

4. Check on more than one engine

Decode paths are an implementation detail, and implementations differ. If your visual review happens entirely in one browser, differences of this kind are invisible by construction.

Open your smallest marks side by side in a Chromium browser and in Firefox at the size they actually ship at. That comparison takes a minute and is the only reliable way to see this.

What device pixel ratio does to the arithmetic

One complication worth holding in your head, because it explains why this shows up on some machines and not others.

A 32 CSS pixel slot on a standard display needs 32 device pixels. The same slot on a 2x display needs 64, and on a 3x phone it needs 96. The decode target is in device pixels, so the reduction ratio changes with the screen.

Take a 512px source in a 32px slot. On a 1x screen that is a 16x reduction. On a 2x screen it is 8x. On a 3x screen it is closer to 5x. Different machines are asking for different decode scales from the same file on the same page.

Which is the practical reason two colleagues comparing screens can genuinely disagree about a logo, without either of them being careless. It also means a laptop with an external monitor can render the same mark two ways depending on which display the window is on.

The uncomfortable corollary: a high-resolution screen can hide this from the person most likely to be looking for it. Designers tend to work on the best display in the building.

Choosing a format by what the asset is

AssetFormatWhy
Logo, wordmark, iconSVGGeometry, so nothing is discarded at any size
Mark with no vector sourcePNG or WebPLossless or near-lossless, keeps hard edges
Screenshot with UI textPNG or WebPText is high frequency and JPEG smears it
Photograph, hero, article imageJPEG, WebP or AVIFWhat these formats were designed for
Avatar from user uploadWebP, resized on uploadYou cannot control the source, so control the output

Nothing on that list is new advice. What the decode story adds is a reason beyond file size, which is the argument that usually loses when someone has already uploaded the JPEG and the page is shipping tomorrow.

The WordPress and block theme angle

Two places where a WordPress site walks straight into this.

The first is the site logo. A custom logo is uploaded once, at whatever size the client had, and rendered by the Site Logo block at whatever width the theme sets. A 2400px JPEG logo displayed at 40px is the exact shape of this problem, and it is an extremely common upload.

The second is the registered image size set. WordPress generates its intermediate sizes from the original, so if a block requests a size that does not exist, the browser is handed something larger and asked to sort it out.

// Register a size that matches where the mark is actually used.
add_action( 'after_setup_theme', function () {
    add_image_size( 'brand-mark-small', 96, 96, true );
} );

Worth stating clearly for anyone who reaches for theme.json first: this is not a Global Styles problem and there is no key that fixes it. theme.json controls presentation, and this is decided in the asset pipeline and the browser’s decoder. The nearest thing to a design-system lever is the discipline of registering sizes that match your components and shipping vector where the artwork allows.

That is a recurring shape in block theme work. Some decisions live in the design system, and some live in a layer the design system cannot reach, and knowing which is which saves a lot of time. We ran into the same split when a contrast fix could not live in PHP, because the colour only resolved in the browser.

Why you have probably never noticed

Worth answering honestly, because a difference nobody has spotted in years is not an emergency and this article should not pretend otherwise.

The effect is small. It is the difference between a stroke that reads at its intended weight and one that reads slightly heavy. On a mark you have looked at ten thousand times you would notice instantly, and on anything else you would not.

It also needs a comparison to be visible at all. One screen on its own looks fine, because there is nothing to judge it against. It takes two machines side by side, or a designer holding the original artwork next to the rendered page, which is exactly the situation that produced the original writeup.

And most sites do not hit the conditions. You need a small display size, a much larger source, a photographic format, and artwork with fine detail. Drop any one of those and there is nothing to see.

So the honest framing is not that your site is broken. It is that when someone does report this, the report is usually correct, and the thing they are describing has a real mechanism behind it rather than being a trick of the light. That is worth knowing before the conversation happens, because the default response to an unreproducible visual complaint is to assume the reporter is mistaken.

How to check your own site

Three checks, none of which need tooling.

Find the small raster assets that are being downscaled hard. In DevTools, hover any image in the Elements panel and compare its intrinsic size against its rendered size. A large gap on a small mark is the signature.

// Small images whose source is far larger than their display size.
[...document.images]
  .filter(img => img.naturalWidth > 0 && img.width > 0)
  .filter(img => img.width <= 64 && img.naturalWidth / img.width >= 4)
  .forEach(img => console.log(
    img.currentSrc,
    `natural ${img.naturalWidth}px, shown ${img.width}px`,
    `ratio ${(img.naturalWidth / img.width).toFixed(1)}x`
  ));

Anything with a ratio near 8 or beyond, in a photographic format, on artwork with fine strokes, is a candidate. Treat that as a list to look at rather than a verdict, since plenty of high-ratio images are photographs that will be fine.

Then check the formats behind them, because the format is the thing you can change fastest.

[...document.images]
  .filter(img => img.width <= 64)
  .filter(img => /\.jpe?g(\?|$)/i.test(img.currentSrc))
  .forEach(img => console.warn('Small JPEG:', img.currentSrc, img.width + 'px'));

Finally, look at the shortlist in two engines at the shipped size. That is the part no script does for you, and it is the part that settles the argument.

The wider point about formats

What makes this a useful story rather than a trivia item is the shape of the mistake underneath it.

Image formats are not neutral containers. Each carries assumptions about what it is holding and what a viewer will notice. JPEG assumes photographs, assumes a human eye more sensitive to broad structure than to fine texture, and the entire format including its decode optimisations follows from that.

Feed it something that breaks the assumption, like a two-colour mark with hairline strokes, and nothing errors. You get an image. It is just quietly worse in the specific way the format was willing to trade away.

Which is the same lesson as most format choices in a design system. The question is not which format is best. It is which one was designed for the thing you are putting in it, and what it decided you would not miss.

That is also the honest reason the advice ends up at SVG for marks. Not because vector is fashionable, but because a logo is geometry, and a format that stores geometry has nothing to discard.

What to do this week

  1. Run the two console snippets on your homepage and one template-heavy page.
  2. Convert any small JPEG mark to SVG where you have the vector, and to PNG or WebP where you do not.
  3. Check the site logo specifically. It is the most likely offender and the most visible one.
  4. Register image sizes that match the components you actually ship, rather than letting the browser reduce a large original.
  5. Look at your smallest marks in two engines once, at shipped size, and settle whether you have this at all.

Most sites will find one or two offenders, usually a client-supplied logo that arrived as a JPEG and was never converted. The fix is minutes. The value is mostly in knowing the difference is real, so the next time a designer says the mark looks heavy in Chrome, nobody spends an afternoon in the stylesheet looking for a rule that was never there.

If you want to go deeper on the format itself, the author points at jpegclub.org for the partial IDCT technique, and the recent additions to the text layer we covered in the piece on five new CSS properties in WebKit are a reminder that the rendering stack keeps changing underneath a design system whether or not anyone is watching.