Back to overview
Breaking Up with SVG-in-JS in 2023

Breaking Up with SVG-in-JS in 2023

— by · 
Posted on: Jun 30, 2023
 · updated 
Posted on: Oct 10, 2024

In December 2022, “Why We’re Breaking Up with CSS-in-JS” made rounds and describes why you no longer want to have CSS inside of your JS bundles. However, CSS isn’t the only thing that lands in JS bundles this day - SVGs do too, as Jason Miller, the author of Preact shows:

SVGs in JS have a cost and SVGs don’t belong into your JS bundle. It’s about time to bring back SVG-in-HTML.
Let’s take a look at better techniques for using SVG in JSX, while keeping our JS bundle small and performant.

You prefer Korean 🇰🇷? You can find the translated article here.

Table of contents

Open Table of contents

How does <svg> end up in JavaScript?

First of all, let’s make sure we know how the SVGs end up inside the JavaScript source-code. Usually, this is done as part of writing JSX:

<!-- HeartIcon.svg -->
<svg viewBox="0 0 300 300">
  <g><path d="M0 200 v-200 h200 a100,100 90 0,1 0,200 a100,100 90 0,1 -200,0z" /></g>
</svg>
// App.jsx
import HeartIcon from "./HeartIcon.svg";
const App = () => <HeartIcon fill="red" />

To make the .svg file import work, bundlers need to be told what to do with files that are not JavaScript (or TypeScript). A Webpack loader like svgr is commonly used – it transforms the .svg file into a React component. It allows you to conveniently add attributes (like fill="red") to the SVG tag:

// HeartIcon.svg after svgr (simplified and before JSX transformation) - ⚠️️ don't copy this.
export default props => (
  <svg viewBox="0 0 300 300" {...props}>{/* ⬅️ note the props spread */}
    <g><path d="M0 200 v-200 h200 a100,100 90 0,1 0,200 a100,100 90 0,1 -200,0z" /></g>
  </svg>
);

After rendering, the output might look like this:

<svg viewBox="0 0 300 300" fill="red"><!-- ⬅️ the difference is the `fill` attribute -->
  <g><path d="M0 200 v-200 h200 a100,100 90 0,1 0,200 a100,100 90 0,1 -200,0z" /></g>
</svg>

Granted, this is very convenient and easy to use, but the ease of use comes with a drawback your users have to pay…

Performance Deep Dive: Why SVG-in-JS is an anti-pattern

… so, why would you not want to have SVG code inside your JS bundle?

Parsing & Compilation

JavaScript parsing & compilation is not free – the more you have inside your bundle, the longer JavaScript engines will take to go through the source code.

On a fast M2 laptop, the difference might not be obvious, but as Alex Russell from the Microsoft Edge team reports year-by-year, there is a performance equality gap. As he puts it, a Samsung Galaxy A50 and the Nokia G11 are the best devices you can buy to understand world-wide p75 users. Web development should be inclusive for everyone, and not only for wealthy regions.

Byte-for-byte, JavaScript is more expensive for the browser to process than the equivalently sized image or Web Font

— Tom Dale, web.dev

SVGs are not JavaScript, they are HTML-like XML tags that describe images. You certainly don’t want images inside your JS. By moving SVGs out of the JS bundle, you are moving them out of the parsing & compilation step. In the following, you’ll see why this is beneficial.

Graph showing that parsing and compilation is done in seperate, so called 'worker threads', while execution happens on the main thread
Visualization of JavaScript download & execution in Chromium. Parsing & Compilation does not block the main thread. V8.dev

Parsing & Compilation is the step right before the execution. So whenever your JavaScript is downloaded and ready to run, the time it takes to parse and the time it takes to compile, is the time where the user is waiting for the interactivity.
For React, you also need hydration on top. Download + Parsing + Compilation + Hydration is the Time-To-Intereactive there. The bigger the component tree, the more needs to be hydrated. The article “JavaScript Start-up Optimization” from Addy Osmani has really gone in-depth on this topic.

On a side-note, compression can also positively impact the processing time, but do not forget that JS engines work with the uncompressed source code. For that, you could move the parsing & compilation to an earlier time by using <link rel="modulepreload">, or by intercepting with a ServiceWorker. In that case, parsing & compilation will be done right after downloading, instead of right before execution. Managing the timing can lead to better results, but does not fix the root cause.

Memory Usage

Anything that is parsed needs to be kept around, which is for the duration of the page in the JavaScript memory heap, and most likely inside of any of the various memory caches browsers have. A Galaxy A50 has 4 GiB of RAM, and your website is not the only application running on that device, so there is not much space left. Be gentle to your users.

Best practices for removing SVGs from the JS bundle

Before we start, the first step is finding out if you have SVG in your JavaScript bundle. This can be done by either inspecting the source code and searching for “svg”, or by using Lighthouse’s in-built bundle viewer. You can access it with the button View Treemap in the “Performance” section of a report.

If you have a non-trivial amount inside your bundle, there are some options that help you get rid of them. The following diagram might help you make a decision in most cases:

Is above-the-fold? Inline in HTML. Is less-than-equal to 4 kB / used once? Inline in HTML. Has static colors? Use the <img> tag. Loaded from same-site domain? Use SVG sprites. If nothing matches, use CSS background-image/mask-image. Is above-the-fold? Inline in HTML Is ≤ 4 kB & used once? no yes yes <img loading="lazy"> Has static colors? SVG sprite & <use> Loaded from same-site? CSS `mask-image` or `filter` yes yes no no no

Each of those options is explained in the following.

Using <img> to load the SVG

In order to use SVGs inside of <img> tags, you have to tell your bundler/framework to externalize them (or in other words: to create a static URL). For Webpack, this can be done by updating the Webpack config to set all .svg files to the type asset/resource1:

const config = {
  // … other parts of the Webpack config …
  module: {
    rules: [
      {
        test: /\.svg/,
        type: "asset/resource",
      },
    ],
  },
};

Other frameworks, such as Astro, might do this automatically. Make sure your infrastructure applies Brotli/Gzip compression, as the svg file format can be compressed like JS files.

Referencing the SVG then becomes as easy as regular (PNG/JPG/…) images:

import HeartIcon from "./HeartIcon.svg";

const App = () => <img src={HeartIcon} loading="lazy" />;

I would recommend to use single out-of-viewport SVGs like this. However, if you need to style them or have a lot of them, you should probably pick one of the other options.

SVG sprites – using <use>

If we want to use fill and other (custom) CSS properties, or currentcolor as value, we need to use the <use> tag that allows us to load SVGs. Together with the same Webpack rule as above, we can reference the SVG like this:

import HeartIcon from "./HeartIcon.svg";

const App = () => <svg><use href={`${HeartIcon}#heart`} /></svg>;

If you look carefully, you can see we reference an ID, which is needed for <use>. Let’s update our HeartIcon SVG:

<svg viewBox="0 0 300 300" id="heart">...</svg>

If you have many SVGs on your site, you can put them all into one file. SVG sprites are built using the <symbol> tag. You need to give them an ID, so you can <use> them (literally). Styling, currentcolor et. all works like before:

<!-- icons.svg -->
<svg>
  <!-- 1: add a `<defs>` tag -->
  <defs>
    <!-- 2: wrap in `<symbol>` and give it an ID (and other attributes such as `viewBox`) -->
    <symbol id="icon1">
      <!-- 3: paste the content of the SVG inside of the `<symbol>` -->
      ...
    </symbol>
    <symbol id="icon2">...</symbol>
  </defs>
</svg>

The sprite file will only be loaded once and will be cached. We can now reference the SVGs in the following way:

<svg><use href="icons.svg#icon1" /></svg>
<svg><use href="icons.svg#icon2" /></svg>

Writing your own SVG sprite takes some time if done manually, but fear not, I’ve collected open-source solutions which automate this in the ➡️ tools chapter.

Remove even more JS: CSS & currentcolor for attributes like fill, stroke, width, height etc.

While you could write code like this:

// 😐 not optimal
const Icon = (favColor, width) => (
  <svg><use href={`${HeartIcon}#heart`} fill={favColor ? favColor : "red"} width={width} /></svg>
);
const App = () => (
  <><Icon favColor="#FFFF00" /><Icon width={300} /></>
);

I do not recommend doing so. The logic ends up in the JavaScript bundle again and needs to be executed by the JavaScript engine. Similar to avoiding CSS-in-JS, we want to have class names instead, so we can use CSS to style the SVG:

// ✅ better
const Icon = (className) => (
  // add a class and let any consumer handle the details via CSS ⬇️
  <svg><use href={`${HeartIcon}#heart`} className={`heart ${className}`} /></svg>
);
const YellowHeart = () => <Icon className="yellow" />;
const BigHeart = () => <Icon className="big" />;
const App = () => <><YellowHeart /><BigHeart /></>;

You can now use currentcolor to make the SVGs inherit the color from the CSS attribute color:

/* 👍 good */
.heart { fill: currentcolor } /* ⬅️ apply the current `color` */

/* ⬇️ those classes might even come from your design system and don't need to be SVG specific */
.big { width: 300px }
.yellow { color: #FFFF00 }

or even better if you have access to the SVG:

<!-- HeartIcon.svg -->
<svg viewBox="0 0 300 300" id="heart">
  <g>
    <path
      d="M0 200 v-200 h200 a100,100 90 0,1 0,200 a100,100 90 0,1 -200,0z"
      fill="currentcolor"
    /><!-- ⬆️ add `currentcolor` to `fill`/`stroke` -->
  </g>
</svg>
/* 🏆 optimal */
.heart { /* `fill` is no longer needed in the CSS */ }

Any CSS attribute applied to the <use> tag, applies the styling to the <svg>/<symbol> tag of the referenced SVG automatically. Any CSS attribute applied to the <use> tag, applies the styling to the <svg>/<symbol> tag of the referenced SVG automatically. So you can easily add CSS prop stroke to the .heart style without causing color changes on the <path> element.

Server Components (React)

Very recent, but also a viable solution that does not require too many changes, would be adopting the upcoming Server Components from React, which can be used in e.g. NextJS 13.4. They are especially helpful when you need to change behaviour of components at runtime and allow you to write JSX that is only executed on the server. This way, the SVGs are not part of the JavaScript bundle shipped to browsers anymore. In order to keep them on the server only, it is as simple as not adding 'use client' to the file.

In the case of CORS: CSS to the rescue

SVG <use> elements don’t currently have any way to ask for cross-origin permissions. They just don’t work cross-origin, at all.

O’Reilly Media book by Amelia Bellamy-Royds, Kurt Cagle, and Dudley Storey

In order to use external assets with <use>, you must load the (sprite) SVG from the same domain. If you have to use a CDN, you’ll run into the quoted limitation.

You now have two options:

.heart {
  mask-image: url("somecdn.com/HeartIcon.svg#heart");
  /* ⬇️ 'color' the SVG */
  background-color: currentcolor;
}

However, using this approach has the same drawback as CSS background images used on LCP elements:
Browsers need to download and execute the CSS first before they can discover and download the SVG, which increases the time until the SVG shows up meaningfully. This can be mitigated by using <link rel="preload" as="image"> or by inlining the CSS. Additionally, all caveats of <img> apply to the SVG itself as well (a mask is also not part of the DOM).
One advantage is, the browser won’t download the mask image, if the element is hidden (with display: none).

Performance vs. Time-To-Load: To inline or not to inline?

Inlining allows us to save one HTTP request, so SVGs are displayed immediately. The downside is, which is the same as for critical CSS, the bytes are downloaded on every non-browser-cached page. Also, inline SVGs are part of the DOM and thus will increase the amount of calculations browsers need to do6. So, while we decrease the amount the JavaScript engine has to do, we do not want to meaningfully slow-down the time-to-download of the HTML response or blow up the DOM.

Thus, we can derive a few rules for what to inline:

  1. Logos have the highest priority, a logo makes your users recognize your brand. Some websites even measure the time to load the logo (even if not the LCP element). While the LCP element is an important factor for SEO (as it is a Core Web Vital), visual completeness implies “the website is usable now” for most users. It also prevents a visual “flicker” or maybe even CLS.

  2. Next, icons in the viewport, e.g. a search or hamburger icon. Those are more likely to be touched by users, so if they load late, it impacts the experience of your users. By now you might see parallels to critical CSS: Everything above-the-fold is more important, anything below not so much.

  3. The rest. Do not inline them and if possible lazy-load.

For the inlining method itself, either use the fastest technique from Cloudfour’s SVG icon benchmark:
<img src="data:image/svg+xml,%3Csvg xmlns='...", or if you run into the caveats, embed <svg> directly.

If you happen to exceed the budget, benchmark first and if needed, use a technique that does not inline and try boosting the resource priority with <link rel="preload" as="image">.

If you decide to inline and want to maximize caching benefits, you could inline icons for 1st-time-visitors, prefetch (<link rel="prefetch">) the sprite SVG in the background and for subsequent visits, only load the sprite SVG (e.g. by checking a cookie value on the server).

Inlining SVGs without polluting the JS bundle

Now that we know what to inline, let’s see how to do it without adding the SVG back into our JS bundle again. In the following, we inline our Heart icon right after <body>, so any SVG referencing the sprite will show up right from the start. You could use the same technique for less important sprites, by placing the sprite output right before </body>.

import fs from "node:fs";

const svgIcons = await fs.readFile("path/to/icons.svg"); // ← load SVG file content

// … router might come from HTTP frameworks like fastify or express …
app.get("/", function () {
  const reactOutput = renderToString(App);
  return `<!doctype html><head><title>SVG-in-HTML</title></head>
     <body>
      <!-- ⬇️ output the SVG sprite to be re-used and make it invisible -->
      <div style="display:none">${svgIcons}</div>

      ${reactOutput}
    </body>`;
});

Now we need to make sure any instance where the SVG should be used does not link to the file, but rather only to the ID:

<!-- Note the missing file path in the `href` attribute. -->
<svg><use href="#heart"></svg>

Be careful though, IDs now are global and not only inside of the SVG file.

Voilà, SVG-in-HTML. This could be expanded to extract all the IDs used per page/output, so it only spills out the SVGs that are actually used instead of all of them. used-styles is a good example of how to do this for critical CSS.

Wrap Up

By using the described techniques, you can make your JavaScript bundle smaller and more performant, which helps slow and old devices, and creates a more inclusive internet.

Aside from that and as always for web-performance related topics, there might be other lower-hanging-fruits, or more important things to optimize. So before you jump right into optimizing your SVGs, keep in mind, SVG-in-JS might not be your biggest culprit.  Measure, then optimize.
Tip: A Ctrl+F for <svg> might be enough to give you an idea.

Other things to make your JS bundle smaller: ship Preact instead of React, ship Redaxios instead of Axios, look through Luke Edward’s module collection that replaces bigger modules (e.g. uuid, clsx), replace CSS-in-JS with either a solution that has no runtime overhead like ecsstatic/kuma UI/Panda, or with e.g. CSS modules. There are also some Webpack plugins which replace bigger modules with smaller ones. Bundlephobia is your friend for finding smaller modules.

Tools / Snippets

Ben Adam has written a similar piece, where he shows a snippet on how to manage SVG sprites in React.

Cloudfour’s SVG Icon Stress Test, where Tyler Sticka presents a rendering performance benchmark of various techniques.

Epic Stack + SVG sprites - “using rmx-cli to automate the sprite generation”

JetBrains SVG sprite loader - “Webpack loader for creating SVG sprites.”

Icon-pipeline - ”🚚 SVG icon pipeline - Optimize icons & build SVG sprites”

SVGomg - “SVG Optimizer’s Missing GUI”

@svg-use - “a set of tools and bundler plugins, to ergonomically load SVG files as components, via SVG’s <use href> mechanism”

Footnotes & Comments

Thank you Barry Pollard and Kevin Farrugia for the invaluable feedback for this post.

Footnotes

  1. Webpack Docs. As an alternative, you could do new URL('path/to/svg.svg', import.meta.url) for single SVGs. If you want to keep using svgr, the svgr docs also mention how to get the URL.

  2. Inline SVGs run on the compositor on DPR = 1x screens, which usually leads to higher performance, as mentioned in this Chromium bug. For devices with a screen over 1x DPR, it is the opposite, <svg> consumes more CPU than <img>. You can try yourself: <img> Codepen and the inline <svg> Codepen. Use DevTools -> Ctrl + Shift + P -> Performance monitor. 2

  3. Alfredo Lopez made me aware of a clever trick, setup by one of his colleagues: You could use query parameters (e.g. HeartIcon.svg?color=green) to dynamically embed <style>:root { --color: green }</style> into the SVG, so that CSS custom props can be used (once a blog post about this technique is up, I’ll link it here).

  4. Chromium comment

  5. StackOverflow Reference

  6. web.dev article regarding DOM size

  7. Astro docs

  8. web.dev article regarding critical CSS


Previous Post
Fastest Way of Passing State to JavaScript, Re-visited
Next Post
The modern way of serving images
⬆️ Back to top · 📖 Read other posts