completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

SVG and icons

Shrink SVG files with SVGO, choose between inline SVG, sprites, files and icon fonts, and know when a detailed SVG costs more to draw than a photo.

By the end, you can
  • 01
    Optimize an SVG fileRun SVGO on a file a drawing editor saved, say what it removed, and spot what it can't remove.
  • 02
    Ship icons the fast wayChoose between inline SVG, a sprite, SVG files and PNG files, and say what each one costs.
  • 03
    Spot the hidden costsExplain why icon fonts and very detailed SVGs cost more than they seem, using recorded numbers.

Eight icons that came late

Ravi looks after the Leaf and Kettle tea shop's website. Across the top of the mint tea page runs a header: a menu button, the shop's name, then search, account, saved teas and the basket. Under it, a strip says "Free delivery", "Brew guide" and "4.8". Each one has a small picture beside it. That's eight icons in all.

The shop's theme draws those icons with an icon font: a font whose letters are pictures. The font is Bootstrap Icons, a popular free set. Asha opens the page on her phone, on a slow link. Does the way the icons are drawn matter to her?

The course loaded the page five times in Chrome 157, on a phone's screen, with DevTools' Slow 4G and the CPU 4 times slower. Then it did the same with the icons written into the page as SVG, a way this lesson explains:

When the words and the icons appearedPop out in the code editor (opens in a new tab)JavaScript
// What Asha's phone showed, in ms after she opened the page, as recordedconst iconFont = { words: 1288, icons: 2578 };const inlineSvg = { words: 640, icons: 640 }; console.log(`Icon font: words at ${iconFont.words} ms, icons at ${iconFont.icons} ms`);console.log(`Inline SVG: words at ${inlineSvg.words} ms, icons at ${inlineSvg.icons} ms`);
  • Line 1 is a comment. Each time is in milliseconds (ms) after the visit started, the median of five loads: the middle one once they're sorted.
  • Line 2 is the page with the icon font: the words appeared at 1,288 ms, and the icons at 2,578 ms.
  • Line 3 is the same page with the icons as SVG: everything appeared at 640 ms.
  • Lines 5 and 6 print Icon font: words at 1288 ms, icons at 2578 ms and Inline SVG: words at 640 ms, icons at 640 ms.

With the icon font, Asha's screen stayed blank for 1.3 seconds. Then the words came, with gaps where the icons belonged. The icons filled the gaps 1.3 seconds after that. With SVG, the whole header arrived at once, at 640 ms, before the icon font page had shown anything. Here is what her screen showed:

The top of the mint tea page on Asha's phone at 640 ms, 1,288 ms, 2,578 ms. With the icon font, as the shop has it: nothing on the screen, then 0 of 8 icons, then 8 of 8 icons. With inline SVG: 8 of 8 icons, then 8 of 8 icons, then 8 of 8 icons.640 ms1,288 ms2,578 msWith the icon font, as the shop has itnothing on screen yeta blank pageThe top of the mint tea page at 1,286 ms, with 0 of 8 icons0 of 8 iconsThe top of the mint tea page at 2,578 ms, with 8 of 8 icons8 of 8 iconsWith inline SVGThe top of the mint tea page at 640 ms, with 8 of 8 icons8 of 8 iconsThe top of the mint tea page at 640 ms, with 8 of 8 icons8 of 8 iconsThe top of the mint tea page at 640 ms, with 8 of 8 icons8 of 8 icons
The top of the page as Chrome drew it on Asha's phone, at three moments. With the icon font, the screen stayed empty, then showed words with gaps. With inline SVG, the whole header was there from the first paint.
Real-life analogyLabels drawn with a marker, or stamped

Ravi labels eight shelves, each with a little picture. He can draw each picture with a marker as he writes the label, and the labels are done. Or he can use rubber stamps. But the stamps come by post, in a box of every stamp the company makes. Until the box arrives, his labels have blank squares.

In real life: A little picture drawn with a marker, right on the label
In icons: An SVG icon: a few drawing commands
In real life: A rubber stamp
In icons: A letter in the icon font
In real life: The box of every stamp the company makes, sent by post
In icons: The icon font file, with all 2,078 icons
In real life: Blank squares on the labels until the box arrives
In icons: Icons that stay invisible until the font arrives
The main idea

An icon is a tiny drawing. Send it as a small SVG, inside the page or in one shared file, and it can show as soon as the page does. An icon font makes the browser fetch a whole set of icons, late, and hide them until it arrives. And a drawing with thousands of shapes can cost more than a photo.

The rest of the lesson takes four questions, one section each:

  • How small can an SVG file be? Optimizing it with SVGO.
  • Where should the drawing travel? Inside the page, in a sprite, or in its own file.
  • What did the icon font cost? Three hidden costs.
  • When is an SVG too big? Bytes, and the work of drawing it.

Optimizing SVG files

An SVG file is text: drawing commands. In Image formats, the shop's logo was 410 bytes of commands written by hand. Real SVG files rarely start that way. Designers draw them in a drawing editor, and editors save more than the drawing.

To see what they add, the course opened the logo in Fabric.js, a JavaScript library for building drawing tools in the browser. It reads and writes SVG. The course saved the logo again with it, as a tool built on it would. The file grew from 410 bytes to 1,898:

The logo, as the editor saved itSVG
<?xml version="1.0" encoding="UTF-8" standalone="no" ?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="64" height="64" viewBox="0 0 64 64" xml:space="preserve"><desc>Created with Fabric.js 7.4.0</desc><defs></defs><g transform="matrix(1 0 0 1 32 32)"  ><rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(29,107,79); fill-rule: nonzero; opacity: 1;"  x="-32" y="-32" rx="14" ry="14" width="64" height="64" /></g><g transform="matrix(1 0 0 1 30 37)"  ><path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(255,255,255); fill-rule: nonzero; opacity: 1;"  transform=" translate(-30, -37)" d="M 18 26 L 42 26 L 42 36 C 42 42.6274 36.6274 48 30 48 C 23.3726 48 18 42.6274 18 36 Z" stroke-linecap="round" /></g><g transform="matrix(1 0 0 1 46 34)"  ><path style="stroke: rgb(255,255,255); stroke-width: 3; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: none; fill-rule: nonzero; opacity: 1;"  transform=" translate(-46, -34)" d="M 42 29 L 45 29 C 47.7614 29 50 31.2386 50 34 C 50 36.7614 47.7614 39 45 39 L 42 39" stroke-linecap="round" /></g><g transform="matrix(1 0 0 1 32 17)"  ><path style="stroke: rgb(191,230,212); stroke-width: 2.5; stroke-dasharray: none; stroke-linecap: round; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: none; fill-rule: nonzero; opacity: 1;"  transform=" translate(-32, -17)" d="M 27 21 C 27 17 31 17 31 13 M 33 21 C 33 17 37 17 37 13" stroke-linecap="round" /></g></svg>
  • Lines 1 and 2 are an XML declaration and a doctype. They describe the file's format. The picture doesn't need them.
  • Line 3 opens the drawing. Next to the viewBox, the grid the drawing uses, it declares an xlink namespace and a version number. The drawing needs neither.
  • Line 4 is a description: Created with Fabric.js 7.4.0. Lines 5 and 6 are an empty <defs>, a place for shapes to reuse, with nothing in it.
  • Lines 7 to 9 are the green square, inside a group, <g>, that moves it into place. The style on line 8 lists ten properties. Most say what a shape does anyway, like opacity: 1.
  • Lines 10 to 18 are the cup, the handle and the steam, each in its own group, each with a style just as long.
Real-life analogyUnpacking the tags and tissue paper

You buy new clothes and pack them as they came: tags, tissue paper, cardboard and the receipt still in. Take those out, and the same clothes fit in a smaller bag. Nothing you wear is gone.

In real life: The price tags and the receipt
In SVG files: The XML declaration, the doctype and the <desc>
In real life: The tissue paper and the cardboard
In SVG files: Empty <defs>, extra groups, and spaces
In real life: The clothes
In SVG files: The drawing commands
In real life: A smaller bag
In SVG files: Fewer bytes

SVGO's README says where this comes from: SVG files, "especially those exported from vector editors, usually contain a lot of redundant information." It lists "editor metadata, comments, hidden elements, default or suboptimal values". SVGO, the SVG Optimizer, is a Node.js tool that removes them. Here is how to run it from a script:

Optimizing one file with SVGOJavaScript
import { readFileSync, writeFileSync } from "node:fs";import { optimize } from "svgo"; const saved = readFileSync("logo.svg", "utf8");const result = optimize(saved, { multipass: true });writeFileSync("logo.min.svg", result.data);
  • Line 1 loads Node.js's functions for reading and writing files. Line 2 loads SVGO's optimize.
  • Line 4 reads the saved logo.
  • Line 5 runs SVGO with its default settings, called preset-default. multipass: true runs them again, up to 10 times, while the file keeps shrinking. One cleanup can make room for another.
  • Line 6 writes the result to a new file.

The course ran it on the logo. Then it ran it again with one more plugin, explained below:

The logo's bytes after each stepPop out in the code editor (opens in a new tab)JavaScript
// The logo's bytes after each step, as recordedconst saved = 1898;const svgoDefaults = 1112;const stylesAsAttributes = 490; console.log(`SVGO's defaults: ${Math.round((1 - svgoDefaults / saved) * 100)}% smaller`);console.log(`Styles as attributes too: ${Math.round((1 - stylesAsAttributes / saved) * 100)}% smaller`);
  • Line 2 is the saved file: 1,898 bytes. Line 3 is SVGO's defaults: 1,112. Line 4 is the extra plugin too: 490.
  • Lines 6 and 7 print SVGO's defaults: 41% smaller and Styles as attributes too: 74% smaller.

Step through the three files. Watch what disappears at each step:

Playground: the logo, one SVGO step at a time
The file
  1. Bytes1,898
  2. With gzip677
  3. With Brotli553
  4. Pixels drawn differently, of 65,5360
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="64" height="64" viewBox="0 0 64 64" xml:space="preserve">
<desc>Created with Fabric.js 7.4.0</desc>
<defs>
</defs>
<g transform="matrix(1 0 0 1 32 32)"  >
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(29,107,79); fill-rule: nonzero; opacity: 1;"  x="-32" y="-32" rx="14" ry="14" width="64" height="64" />
</g>
<g transform="matrix(1 0 0 1 30 37)"  >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(255,255,255); fill-rule: nonzero; opacity: 1;"  transform=" translate(-30, -37)" d="M 18 26 L 42 26 L 42 36 C 42 42.6274 36.6274 48 30 48 C 23.3726 48 18 42.6274 18 36 Z" stroke-linecap="round" />
</g>
<g transform="matrix(1 0 0 1 46 34)"  >
<path style="stroke: rgb(255,255,255); stroke-width: 3; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: none; fill-rule: nonzero; opacity: 1;"  transform=" translate(-46, -34)" d="M 42 29 L 45 29 C 47.7614 29 50 31.2386 50 34 C 50 36.7614 47.7614 39 45 39 L 42 39" stroke-linecap="round" />
</g>
<g transform="matrix(1 0 0 1 32 17)"  >
<path style="stroke: rgb(191,230,212); stroke-width: 2.5; stroke-dasharray: none; stroke-linecap: round; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: none; fill-rule: nonzero; opacity: 1;"  transform=" translate(-32, -17)" d="M 27 21 C 27 17 31 17 31 13 M 33 21 C 33 17 37 17 37 13" stroke-linecap="round" />
</g>
</svg>
Try it yourself
1,898 bytes, as the editor saved it

Read the file: a declaration, a doctype, a description, empty defs, a group around every shape, and every color inside a long style attribute.

The files are the real ones: Fabric.js 7.4.0 saved the logo, and SVGO 4.1.0 ran on it. Chrome 157 drew each one at 256 by 256 pixels to count the changed pixels.

SVGO's defaults removed the declaration, the doctype, the description, the empty <defs> and the groups. They rounded numbers to three decimal places, so 6.6274 became 6.627, and wrote the curves in shorter commands. But the long style attributes stayed.

The extra plugin is convertStyleToAttrs. SVGO's docs say these properties "can be used in both attributes and styles, but in most cases it'll take fewer bytes to use attributes." Once each one is an attribute, the defaults can see that opacity="1" says nothing new, and drop it. You turn it on in SVGO's config file:

SVGO's defaults, plus one pluginJavaScript
// svgo.config.mjsexport default {  multipass: true,  plugins: ["preset-default", "convertStyleToAttrs"],};
  • Line 1 is the file's name. SVGO reads it by itself when it runs from the command line.
  • Line 3 turns on multipass. Line 4 runs the defaults, then the extra plugin.

Did the picture change?

The README promises changes "without impacting rendering". The course checked. Chrome drew the saved file and each SVGO file at 256 by 256 pixels. In both SVGO files, 36 of the 65,536 pixels came out different, and the rest matched exactly. That's about 0.05% of the picture, from the rounded numbers and the rewritten curves.

Two of SVGO's choices keep pictures safe. Since version 4, it keeps the viewBox. Its migration guide says removeViewBox "is no longer a default plugin to preserve scalability": without a viewBox, a drawing can't grow or shrink to fit. And its removeDesc plugin only removes a description that is empty or names the editor, like "Created with". Its docs say a description "is a crucial aspect of accessibility for users of assistive technologies."

Icons that are already optimized

Bootstrap Icons runs SVGO in its own build: its README says "Optimize our SVGs with SVGO." So the course ran SVGO on the eight icons as shipped. Their 5,416 bytes became 5,381. The 35 bytes were all line breaks and spaces. A file that has been through SVGO has nothing left to remove.

Why it matters: run SVGO once, in your build, on every SVG that comes from a drawing editor. Check the result in a browser. If the files still carry long styles, add convertStyleToAttrs.

Inline SVG, a sprite, or a file

An optimized icon still has to reach the page. The course put the same eight Bootstrap Icons, after SVGO, on the mint tea page in four ways, plus the icon font. Here are the four, for the basket icon.

Inline SVG: the drawing inside the HTML

Inline SVG means writing the <svg> element straight into the page's HTML:

The basket, inlinePop out in the code editor (opens in a new tab)HTML
<a href="#cart" aria-label="Basket">  <svg width="24" height="24" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">    <path d="M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .491.592l-1.5 8A.5.5 0 0 1 13 12H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5M3.102 4l1.313 7h8.17l1.313-7zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4m7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4m-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2m7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>  </svg></a>
  • Line 1 opens the basket link. Its aria-label gives it a name for screen readers.
  • Line 2 opens the drawing: 24 by 24 CSS pixels on the page, drawn on a grid of 16 by 16 units, its viewBox. fill="currentColor" paints it in the text color around it. aria-hidden hides the drawing from screen readers, because the link already has a name.
  • Line 3 is the basket's shape: one path, from Bootstrap Icons after SVGO. Lines 4 and 5 close the drawing and the link.

MDN says the currentColor keyword "represents the value of an element's color property." The page's CSS makes the header's links green, so the icons came out green. MDN's guide to SVG lists the costs: inline SVG "saves an HTTP request", but it "increases the size of your HTML file". And the browser "cannot cache inline SVG as it would cache regular image assets", so every page brings its icons again.

An SVG sprite: one file, many icons

A sprite is one file that holds every icon. Each icon is a <symbol>: a drawing with an id that isn't shown by itself, only where something points to it.

The sprite file, shortenedSVG
<!-- icons.svg: one <symbol> for each icon, shortened here --><svg xmlns="http://www.w3.org/2000/svg">  <symbol id="list" viewBox="0 0 16 16"><path fill-rule="evenodd" d="…"/></symbol>  <symbol id="cart" viewBox="0 0 16 16"><path d="…"/></symbol>  <!-- six more symbols --></svg>
  • Line 1 is a comment. The real file holds all eight icons.
  • Line 2 opens the file. Lines 3 and 4 are two symbols, each with an id and its own viewBox.

The page points at a symbol with <use>:

The basket, from the spriteHTML
<a href="#cart" aria-label="Basket">  <svg width="24" height="24" fill="currentColor" aria-hidden="true">    <use href="icons.svg#cart"/>  </svg></a>
  • Line 2 opens an empty drawing, 24 by 24, painted with currentColor.
  • Line 3 copies the cart symbol out of icons.svg. The # separates the file from the symbol's id, like a link to one heading on a page.

Keep the sprite on your own site. MDN warns that browsers "may refuse to load a cross-origin URL" in <use>, and "There is currently no defined way to set a cross-origin policy" for it. Bootstrap's README says the same: serve the sprite with your application.

SVG and PNG files in an image tag

The basket as a fileHTML
<img src="icons/cart.svg" width="24" height="24" alt="">
<img src="icons/cart.png" width="24" height="24" alt="">
  • Line 1 shows the basket's SVG file as an image.
  • Line 2 shows a PNG of it instead: a grid of 72 by 72 pixels. That's three device pixels for each CSS pixel, so it stays sharp on screens up to 3x (see Responsive images).
Real-life analogyPictures in a letter, on a sticker sheet, or by parcel

You can draw a picture inside each letter you send. It arrives with the letter, but you draw it again every time. You can send a friend one sheet of stickers, then write "use sticker 3" in each letter. Or you can post each picture in its own small parcel.

In real life: A picture drawn inside the letter
In icons: Inline SVG, inside the HTML
In real life: One sheet with all your stickers, sent once
In icons: The sprite file
In real life: "Use sticker 3" in each letter
In icons: <use href="icons.svg#cart">
In real life: A small parcel for each picture
In icons: One request for each icon file

What each way cost

The course loaded the page each way five times, taking turns:

The same eight icons, five waysPop out in the code editor (opens in a new tab)JavaScript
// The same eight icons, five ways, as recorded: requests, bytes over the network,// and when all eight icons showed, in ms (the median of 5 loads)const ways = {  "Inline SVG": [1, 1823, 640],  "SVG sprite": [2, 1930, 1188],  "SVG files": [9, 2841, 1251],  "PNG files": [9, 11643, 1288],  "Icon font": [3, 145038, 2578],}; for (const [way, [requests, bytes, ms]] of Object.entries(ways)) console.log(`${way}: icons at ${ms} ms, ${bytes} bytes, requests: ${requests}`);
  • Lines 1 and 2 are comments. Bytes are what crossed the network: Brotli for text, as it is for PNG and font files.
  • Lines 4 to 8 hold each way's requests, bytes, and the moment all eight icons were on the screen.
  • Line 11 prints a line per way, from Inline SVG: icons at 640 ms, 1823 bytes, requests: 1 to Icon font: icons at 2578 ms, 145038 bytes, requests: 3.
The same eight icons, five ways, the median of 5 loads each. Inline SVG, 1 request: words at 640 ms, all icons at 640 ms. SVG sprite, 2 requests: words at 640 ms, all icons at 1,188 ms. SVG files, 9 requests: words at 648 ms, all icons at 1,251 ms. PNG files, 9 requests: words at 656 ms, all icons at 1,288 ms. Icon font, 3 requests: words at 1,288 ms, all icons at 2,578 ms.Inline SVG1 request640 msSVG sprite2 requests1,188 msSVG files9 requests1,251 msPNG files9 requests1,288 msIcon font3 requests2,578 ms0 s1 s2 s3 s
Each bar starts when the words appeared. The plain part is words without all the icons. The shaded part starts when all eight icons were on the screen.

Read the picture from the top:

  • Inline SVG needed one request. The drawings came inside the HTML, so the icons came with the words.
  • The sprite needed one more, for icons.svg. Its icons came 548 ms after the words: one more trip across the slow link.
  • SVG files and PNG files needed a request per icon, nine in all. The icons appeared one or two at a time, as each file arrived. The PNGs were 11,643 bytes, four times the SVG files' 2,841.
  • The icon font needed three requests, one after another. The next section takes them apart.

Step through each way's frames. Look at the icons' color too:

Playground: the same eight icons, five ways
What Asha saw
  1. The top of the mint tea page at 1,286 ms, with 0 of 8 icons1,286 ms: 0 of 8 icons
  2. The top of the mint tea page at 2,578 ms, with 8 of 8 icons2,578 ms: 8 of 8 icons
  1. Requests3
  2. Bytes over the network145,038
  3. Words on the screen1,288 ms
  4. All 8 icons on the screen2,578 ms
  5. The icons' colorthe page's green
Try it yourself
Icon font: all 8 icons at 2,578 ms

Each picture is a frame Chrome drew, in order: 0 of 8 icons at 1,286 ms, 8 of 8 icons at 2,578 ms.

Recorded in Chrome 157, on a phone's screen, with DevTools' Slow 4G and a CPU 4 times slower, over HTTP/2. The times are medians of 5 loads; the frames come from the load with the middle icons time.

The SVG files came out black. In an <img>, the file is drawn as its own picture, apart from the page, so its currentColor never got the page's green. MDN's guide puts it this way: to control an SVG in an image with CSS, "you must include inline CSS styles in your SVG code." So each file would need its own color.

Read across a row to compare the five ways:

Five ways to put eight icons on a page
WayRequests for the 8 iconsShown with the first paint?Takes the page's text color?Cached apart from the page?
Inline SVGOne: the page itself.Yes. All eight came with it.Yes, through currentColor.No. They come again inside every page's HTML.
SVG spriteTwo: the page and icons.svg.No. They came one round trip later.Yes, through currentColor.Yes. One file serves every page.
SVG filesNine: the page and one file per icon.No. They came one at a time.No. They came out black.Yes. Each file is cached on its own.
PNG filesNine: the page and one file per icon.No. They came one at a time.No. The green is in the pixels.Yes. Each file is cached on its own.
Icon fontThree: the page, a stylesheet and the font.No. Even the words waited for the stylesheet.Yes. The icons are letters.Yes. But all 2,078 icons come together.

Why it matters: for a few icons that must show with the page, inline them. For icons that repeat on every page, a sprite costs one request, once, and then comes from the cache (see HTTP caching in depth). PNG icons cost the most bytes and lose the text color.

Why icon fonts cost more than they seem

An icon font looks simple. You load one stylesheet, and write one tag per icon:

The basket, from the icon fontHTML
<link rel="stylesheet" href="bootstrap-icons.min.css"> <a href="#cart" aria-label="Basket"><i class="bi bi-cart" aria-hidden="true"></i></a>
  • Line 1 loads Bootstrap Icons' stylesheet.
  • Line 3 is the basket: an empty <i> with two classes. bi gives it the icon font, and bi-cart picks the basket. aria-hidden hides it from screen readers, as Bootstrap's README writes it.

Inside the stylesheet, two rules do the work:

Two rules from Bootstrap Icons' stylesheetCSS
@font-face {  font-display: block;  font-family: "bootstrap-icons";  src: url("./fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a") format("woff2"),url("./fonts/bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff");} .bi-cart::before { content: "\f242"; }
  • Lines 1 to 6 describe the font. Line 2, font-display: block, comes up below. Lines 4 and 5 name its files: WOFF2 first, then WOFF.
  • Line 8 puts a character before the basket's <i>: the one with the code F242, which the font draws as a basket.

So each icon is a letter in a font. Here are the page's requests, in the load the frames came from:

The icon font page's requestsPop out in the code editor (opens in a new tab)JavaScript
// The icon font page's requests, in ms after the visit started, as recordedconst css = { file: "bootstrap-icons.min.css", start: 592, end: 1226 };const font = { file: "bootstrap-icons.woff2", start: 1237, end: 2558 }; console.log(`The font was asked for ${font.start - css.end} ms after the stylesheet arrived`);console.log(`It took ${font.end - font.start} ms to come`);
  • Line 2 is the stylesheet: asked for at 592 ms, in at 1,226 ms.
  • Line 3 is the font: asked for at 1,237 ms, in at 2,558 ms.
  • Line 5 prints The font was asked for 11 ms after the stylesheet arrived. Line 6 prints It took 1321 ms to come.
The icon font page's requests, one load. index.html, 515 bytes: from 0 ms to 575 ms. bootstrap-icons.min.css, 10,511 bytes: from 592 ms to 1,226 ms. bootstrap-icons.woff2, 134,012 bytes: from 1,237 ms to 2,558 ms. The words appeared at 1,288 ms and the icons at 2,578 ms.words, 1,288 msicons, 2,578 msindex.html515 bytesbootstrap-icons.min.css10,511 bytesbootstrap-icons.woff2134,012 bytes0 s1 s2 s
One request had to finish before the next could start. The words waited for the stylesheet. The icons waited for the font, which the browser only asked for once the stylesheet was in.

Three costs show in the picture:

  • A stylesheet before anything shows. Like any stylesheet in the page's head, this one is render-blocking: the browser draws nothing until it arrives. It holds a rule for every icon in the set: 87,008 bytes, 10,511 with Brotli. The words waited for it, and came at 1,288 ms instead of 640.
  • A font found late. The browser learned about the font from the stylesheet. web.dev explains: "the browser can only begin downloading them after it has downloaded the style sheet. This makes web fonts late-discovered resources". The CSS Fonts spec adds that fonts are "only downloaded when used within a document".
  • Invisible until it comes. font-display: block gives the font a "short block period (3s is recommended in most cases)", the spec says. During it, "the browser draws "invisible" text". That's the sign under a sheet from Remove render delay: the icons were in place, under a sheet, until 2,578 ms.

And the font holds every icon in the set, not only the eight the page uses:

Eight icons, the whole setPop out in the code editor (opens in a new tab)JavaScript
// Bootstrap Icons, as shippedconst iconsInTheFont = 2078;const iconsOnThePage = 8;const fontBytes = 134012;const stylesheetBytes = 87008; console.log(`The page uses ${iconsOnThePage} of ${iconsInTheFont} icons: ${((iconsOnThePage / iconsInTheFont) * 100).toFixed(1)}%`);console.log(`It downloads all of them: ${fontBytes} bytes of font, ${stylesheetBytes} bytes of stylesheet`);
  • Lines 2 and 3: Bootstrap Icons has 2,078 icons, and the page uses 8.
  • Lines 4 and 5: the font is 134,012 bytes, and its stylesheet 87,008.
  • Line 7 prints The page uses 8 of 2078 icons: 0.4%. Line 8 prints It downloads all of them: 134012 bytes of font, 87008 bytes of stylesheet.

WOFF2 is compressed already. Brotli would make the font 5 bytes bigger, so all 134,012 bytes crossed Asha's slow link. With the eight icons inline, the whole page was 1,823 bytes.

Real-life analogyA tool kit you only learn about from the manual

You unpack a new shelf. Page one of the manual says you need the maker's tool kit. Only now do you order it. The kit holds every tool the maker sells, though you need one. And the shelf stays half-built until it arrives.

In real life: The manual
In icon fonts: The stylesheet
In real life: "You need the maker's tool kit"
In icon fonts: The @font-face rule
In real life: Ordering the kit only after reading the manual
In icon fonts: The font, found late
In real life: Every tool the maker sells, for one screw
In icon fonts: All 2,078 icons, for 8
In real life: The half-built shelf
In icon fonts: Words on the screen, icons invisible

Go back to the playground above and pick Icon font. Its two frames are the gap, then the icons.

Why it matters: an icon font costs a render-blocking stylesheet, a big font found late, and icons hidden until it comes. The same icons as SVG cost a few hundred bytes each and show with the page. If you must keep an icon font for now, keep it off the pages where speed matters most, and move icons to SVG one by one.

Large SVGs and rendering cost

An SVG is drawing commands, so the browser follows every command each time it draws the picture. A few commands are quick. What about thousands?

In Why performance matters, the mint tea picture came back from a design tool with a paper texture: 14,000 faint dots, each its own circle. Here it is, shortened:

The dotted picture, shortenedSVG
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="270" viewBox="0 0 360 270">  <rect width="360" height="270" fill="#eef5ea"/>  …<g fill="#3f6f31" opacity=".08">  <circle cx="0.1" cy="160.7" r="0.9"/>  <circle cx="75.4" cy="65.1" r="0.9"/>  <circle cx="279.2" cy="88.5" r="0.9"/>  … 13,997 more dots like these …</g></svg>
  • Line 1 opens the drawing, 360 by 270. Line 2 is its background. Line 3 stands for the picture's other shapes.
  • Line 4 opens a group of dark green shapes at 8% opacity, so the dots are faint.
  • Lines 5 to 7 are three dots: circles with a radius of 0.9 units. Line 8 stands for the other 13,997.

First, the bytes:

What SVGO did to the dotted picturePop out in the code editor (opens in a new tab)JavaScript
// The dotted picture's bytes, as recordedconst svg = { raw: 580632, brotli: 66481 };const afterSvgo = { raw: 518793, brotli: 63952 }; console.log(`SVGO saved ${svg.raw - afterSvgo.raw} bytes, but only ${svg.brotli - afterSvgo.brotli} over the network`);
  • Line 2 is the picture: 580,632 bytes, and 66,481 with Brotli.
  • Line 3 is the same picture after SVGO's defaults.
  • Line 5 prints SVGO saved 61839 bytes, but only 2529 over the network.

SVGO shortened each dot a little. It can't remove a dot, because each one is part of the picture. And Brotli, which shrinks repeated text well, had already squeezed out most of what SVGO removed. The same picture as an AVIF, 720 by 540 pixels for sharp screens, was 15,853 bytes: less than a quarter of the SVG's Brotli bytes.

Bytes aren't the only cost. Drawing takes work too. Stoyan Stefanov measured it by putting an image that's already downloaded on the page with a click, and timing the click with Event Timing. The course did the same, with the CPU 4 times slower:

One click, one picturePop out in the code editor (opens in a new tab)JavaScript
// One click puts an already-downloaded picture on the page.// Milliseconds until it showed: the median of 5 clicks, with the CPU 4 times slower, as recordedconst draws = { "plain, <img>": 48, "dotted, <img>": 128, "dotted, inline": 200, "dotted, AVIF": 48 }; for (const [picture, ms] of Object.entries(draws)) console.log(`${picture}: ${ms} ms`);
  • Lines 1 and 2 are comments. Each picture was downloaded before the click, so the time is drawing, not downloading.
  • Line 3 holds the median of five clicks for each picture.
  • Line 5 prints four lines, from plain, <img>: 48 ms to dotted, AVIF: 48 ms.
Two shop pages as Chrome drew them: the plain picture, 2,819 bytes, and the dotted picture, 580,632 bytes. They look the same. One click to show the picture, already downloaded, median of 5 clicks: Plain, in an <img>, 48 ms; Dotted, in an <img>, 128 ms; Dotted, inline, 200 ms; Dotted, as an AVIF, 48 ms.The shop's mint tea page with the plain picture, as Chrome drew itThe plain picture2,819 bytesThe shop's mint tea page with the dotted picture, as Chrome drew itThe dotted picture580,632 bytes+14,000 dotsOne click shows the picture, already downloadedPlain, in an <img>48 msDotted, in an <img>128 msDotted, inline200 msDotted, as an AVIF48 ms
The two pictures look the same: the dots are faint. But the dotted one is about 200 times the bytes, and it took longer to draw, as SVG, than the same picture as an AVIF.

The traces show where the time went:

  • In an <img>, the dotted picture took 128 ms. Chrome spent a median 45 ms working out styles and 72.7 ms painting. The plain picture needed 1.9 ms of painting.
  • Inline, it took 200 ms. The page had 14,040 elements, against 12 with the picture in an <img>: every dot became an element. Reading all that markup took 53.7 ms before Chrome even started drawing.
  • As an AVIF, the same picture took 48 ms, like the plain SVG. There were no shapes to follow, only pixels to paint.
Real-life analogyA recipe with 14,000 steps

In Image formats, SVG was a recipe. A recipe with five steps is quick to cook. Add a step for each of 14,000 seeds, placed one by one, and dinner is late. A photo of the dish, though, you just look at.

In real life: A step in the recipe
In SVG: A shape in the SVG
In real life: Cooking the dish
In SVG: Drawing the picture
In real life: Placing 14,000 seeds, one by one
In SVG: Drawing 14,000 dots
In real life: A photo of the dish
In SVG: The AVIF: pixels, ready to show

One more cost hides in the metrics. web.dev lists the elements Largest Contentful Paint looks at, including "<image> elements inside an <svg> element". It adds that "the full <svg> support" may come later. So shapes drawn inline don't count for LCP, however big. An SVG in an <img> counts like any image (see How LCP works).

Try it in your own browser. Pick a picture, then press Draw it:

Playground: draw the mint tea picture in your browser
Your browser
  1. The picture's bytes2,819
  2. Elements the picture addednot drawn yet
  3. Click to next framenot drawn yet
Try it yourself
Choose a picture, then press Draw it

The picture is already built and downloaded. The click only puts it on the page.

This lab runs in your own browser, so your times depend on your device. Press Draw it a few times: the first draw is often slower.

Why it matters: SVG is best for shapes you could count, like icons, logos and simple charts. For textures, photos and maps with thousands of shapes, AVIF or WebP is smaller and quicker to draw. Before you inline a big drawing, check its bytes with Brotli and the elements it adds.

Pick a way for each image

For each image below, ask two questions. Is it a few shapes, or thousands? And does it appear once, or on every page?

Where should each image go?
  • The menu icon at the top of a page that must look complete at its first paint
  • Forty small icons used on every page of a large shop
  • Twelve social media icons in the footer of every page
  • A flat drawing in a blog post, far down the page
  • A photo of a tin of tea
  • A map drawing with 30,000 tiny shapes
  • A background texture made of thousands of dots
Try it yourself
0 of 7 correct

Sort each image into the way it should reach the page.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "SVG is always smaller than a raster image." No. The dotted picture was 66,481 bytes as SVG with Brotli, and 15,853 as an AVIF.
  • "SVGO can make any SVG small." No. It removes extras, not shapes. On the dotted picture, it saved 2,529 bytes over the network.
  • "An icon font is one small file." No. Bootstrap Icons' font is 134,012 bytes for 2,078 icons, behind an 87,008-byte stylesheet.
  • "Icons from a font show when the words show." No. With font-display: block, they stay invisible until the font arrives: 2,578 ms, against 1,288 ms for the words.
  • "A sprite can live on any CDN." No. Browsers may refuse a <use> that points to another origin. Serve the sprite from your own.
  • "Inline SVG is always the fastest way." No. It comes again in every page's HTML, and 14,000 inline dots made 14,040 elements and a 200 ms click.
  • "SVGO removes the viewBox." No. Since version 4, removeViewBox isn't part of its defaults.

Practice exercises

Answer each one first, then open the hints if you need them.

Exercise 1 · Warm-upThree decimal places

This is how SVGO rounds numbers by default. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// SVGO rounds numbers to 3 decimal places by default
const shorten = (n) => Number(n.toFixed(3));

console.log(shorten(42.6274));
console.log(shorten(23.3726));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe missing symbol

    Ravi writes <use href="icons.svg#basket"/>. The code checks whether the sprite has that symbol. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const sprite = `<svg xmlns="http://www.w3.org/2000/svg">
      <symbol id="list" viewBox="0 0 16 16">…</symbol>
      <symbol id="cart" viewBox="0 0 16 16">…</symbol>
    </svg>`;
    const ids = [...sprite.matchAll(/<symbol id="([^"]+)"/g)].map((match) => match[1]);
    
    const href = "icons.svg#basket";
    console.log(ids.includes(href.split("#")[1]));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticePNG icons, one at a time

      In the recorded load with PNG files, how many of the eight icons were on the screen at 1,220 ms?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWhat is this picture made of?

        Open DevTools' Elements panel on this page. Is the first picture, under the opening story, an <img> or an inline <svg>?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFewer dots, still many elements

          Inline, the picture with 1,400 dots adds how many elements to the page?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThe other plugin

            SVGO 4 took two plugins out of its defaults. One is removeViewBox. What is the other?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what had to arrive before the icon or picture could show, and how much work drawing it took.

              SVG and icons quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does the second line of this rounding print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const shorten = (n) => Number(n.toFixed(3));
                
                console.log(shorten(36.6274));
                console.log(shorten(6.6274));

                Choose an answer to see the explanation.

              2. Question 2 of 8In the recording, which way showed all eight icons with the page's first paint?

                Choose an answer to see the explanation.

              3. Question 3 of 8Why did the browser ask for the icon font only after the stylesheet had arrived?

                Choose an answer to see the explanation.

              4. Question 4 of 8In the recording, the icon font's words appeared well before its icons. Why were the icons missing?

                Choose an answer to see the explanation.

              5. Question 5 of 8In the recording, why did the icons in <img> tags come out black instead of green?

                Choose an answer to see the explanation.

              6. Question 6 of 8The shop moves its sprite to a CDN on another origin. What can go wrong?

                Choose an answer to see the explanation.

              7. Question 7 of 8In the recording, why did SVGO barely change the dotted picture's bytes over the network?

                Choose an answer to see the explanation.

              8. Question 8 of 8A big inline SVG drawing is the largest thing on the screen. Can it be the LCP element?

                Choose an answer to see the explanation.

              Key takeaways

              • SVG files from drawing editors carry extras. SVGO removes them; add convertStyleToAttrs for long styles.
              • Inline SVG shows with the page and takes the text color, but comes again in every page's HTML.
              • A sprite is one cached file for every icon. Keep it on your own origin.
              • SVG and PNG files cost a request each. In an image tag, an SVG can't take the page's color.
              • An icon font brings a render-blocking stylesheet and a whole set of icons, found late, invisible until it arrives.
              • Thousands of shapes cost bytes and drawing time. Send textures and detailed pictures as AVIF or WebP.

              Remember the one-liner.
              Icons are tiny drawings: lean SVG, in the page or one sprite. No icon fonts, and no 14,000-dot drawings.

              Sources

              The facts on this page come from these docs:

              Coming next: Video performance. Posters, preload settings, autoplay, lazy loading, and adaptive streaming for fast video.

              CompleteFrontend Clear concepts. Working examples.