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.
- 01Optimize an SVG fileRun SVGO on a file a drawing editor saved, say what it removed, and spot what it can't remove.
- 02Ship icons the fast wayChoose between inline SVG, a sprite, SVG files and PNG files, and say what each one costs.
- 03Spot 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:
1// What Asha's phone showed, in ms after she opened the page, as recorded2const iconFont = { words: 1288, icons: 2578 };3const inlineSvg = { words: 640, icons: 640 };4 5console.log(`Icon font: words at ${iconFont.words} ms, icons at ${iconFont.icons} ms`);6console.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 msandInline 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:
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
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:
1<?xml version="1.0" encoding="UTF-8" standalone="no" ?>2<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">3<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">4<desc>Created with Fabric.js 7.4.0</desc>5<defs>6</defs>7<g transform="matrix(1 0 0 1 32 32)" >8<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" />9</g>10<g transform="matrix(1 0 0 1 30 37)" >11<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" />12</g>13<g transform="matrix(1 0 0 1 46 34)" >14<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" />15</g>16<g transform="matrix(1 0 0 1 32 17)" >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" />18</g>19</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 anxlinknamespace 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. Thestyleon line 8 lists ten properties. Most say what a shape does anyway, likeopacity: 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.
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:
1import { readFileSync, writeFileSync } from "node:fs";2import { optimize } from "svgo";3 4const saved = readFileSync("logo.svg", "utf8");5const result = optimize(saved, { multipass: true });6writeFileSync("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: trueruns 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:
1// The logo's bytes after each step, as recorded2const saved = 1898;3const svgoDefaults = 1112;4const stylesAsAttributes = 490;5 6console.log(`SVGO's defaults: ${Math.round((1 - svgoDefaults / saved) * 100)}% smaller`);7console.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% smallerandStyles as attributes too: 74% smaller.
Step through the three files. Watch what disappears at each step:
- Bytes1,898
- With gzip677
- With Brotli553
- 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>
Read the file: a declaration, a doctype, a description, empty defs, a group around every shape, and every color inside a long style attribute.
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:
1// svgo.config.mjs2export default {3 multipass: true,4 plugins: ["preset-default", "convertStyleToAttrs"],5};- 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:
1<a href="#cart" aria-label="Basket">2 <svg width="24" height="24" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">3 <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"/>4 </svg>5</a>- Line 1 opens the basket link. Its
aria-labelgives 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-hiddenhides 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.
1<!-- icons.svg: one <symbol> for each icon, shortened here -->2<svg xmlns="http://www.w3.org/2000/svg">3 <symbol id="list" viewBox="0 0 16 16"><path fill-rule="evenodd" d="…"/></symbol>4 <symbol id="cart" viewBox="0 0 16 16"><path d="…"/></symbol>5 <!-- six more symbols -->6</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>:
1<a href="#cart" aria-label="Basket">2 <svg width="24" height="24" fill="currentColor" aria-hidden="true">3 <use href="icons.svg#cart"/>4 </svg>5</a>- Line 2 opens an empty drawing, 24 by 24, painted with
currentColor. - Line 3 copies the
cartsymbol out oficons.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
<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).
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:
1// The same eight icons, five ways, as recorded: requests, bytes over the network,2// and when all eight icons showed, in ms (the median of 5 loads)3const ways = {4 "Inline SVG": [1, 1823, 640],5 "SVG sprite": [2, 1930, 1188],6 "SVG files": [9, 2841, 1251],7 "PNG files": [9, 11643, 1288],8 "Icon font": [3, 145038, 2578],9};10 11for (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: 1toIcon font: icons at 2578 ms, 145038 bytes, requests: 3.
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:
1,286 ms: 0 of 8 icons
2,578 ms: 8 of 8 icons
- Requests3
- Bytes over the network145,038
- Words on the screen1,288 ms
- All 8 icons on the screen2,578 ms
- The icons' colorthe page's green
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.
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:
| Way | Requests for the 8 icons | Shown with the first paint? | Takes the page's text color? | Cached apart from the page? |
|---|---|---|---|---|
| Inline SVG | One: the page itself. | Yes. All eight came with it. | Yes, through currentColor. | No. They come again inside every page's HTML. |
| SVG sprite | Two: the page and icons.svg. | No. They came one round trip later. | Yes, through currentColor. | Yes. One file serves every page. |
| SVG files | Nine: 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 files | Nine: 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 font | Three: 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:
1<link rel="stylesheet" href="bootstrap-icons.min.css">2 3<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.bigives it the icon font, andbi-cartpicks the basket.aria-hiddenhides it from screen readers, as Bootstrap's README writes it.
Inside the stylesheet, two rules do the work:
1@font-face {2 font-display: block;3 font-family: "bootstrap-icons";4 src: url("./fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a") format("woff2"),5url("./fonts/bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff");6}7 8.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:
1// The icon font page's requests, in ms after the visit started, as recorded2const css = { file: "bootstrap-icons.min.css", start: 592, end: 1226 };3const font = { file: "bootstrap-icons.woff2", start: 1237, end: 2558 };4 5console.log(`The font was asked for ${font.start - css.end} ms after the stylesheet arrived`);6console.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 printsIt took 1321 ms to come.
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: blockgives 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:
1// Bootstrap Icons, as shipped2const iconsInTheFont = 2078;3const iconsOnThePage = 8;4const fontBytes = 134012;5const stylesheetBytes = 87008;6 7console.log(`The page uses ${iconsOnThePage} of ${iconsInTheFont} icons: ${((iconsOnThePage / iconsInTheFont) * 100).toFixed(1)}%`);8console.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 printsIt 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.
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-facerule - 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:
1<svg xmlns="http://www.w3.org/2000/svg" width="360" height="270" viewBox="0 0 360 270">2 <rect width="360" height="270" fill="#eef5ea"/>3 …4<g fill="#3f6f31" opacity=".08">5 <circle cx="0.1" cy="160.7" r="0.9"/>6 <circle cx="75.4" cy="65.1" r="0.9"/>7 <circle cx="279.2" cy="88.5" r="0.9"/>8 … 13,997 more dots like these …9</g>10</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:
1// The dotted picture's bytes, as recorded2const svg = { raw: 580632, brotli: 66481 };3const afterSvgo = { raw: 518793, brotli: 63952 };4 5console.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:
1// One click puts an already-downloaded picture on the page.2// Milliseconds until it showed: the median of 5 clicks, with the CPU 4 times slower, as recorded3const draws = { "plain, <img>": 48, "dotted, <img>": 128, "dotted, inline": 200, "dotted, AVIF": 48 };4 5for (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 mstodotted, AVIF: 48 ms.
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.
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:
- The picture's bytes2,819
- Elements the picture addednot drawn yet
- Click to next framenot drawn yet
The picture is already built and downloaded. The click only puts it on the page.
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?
- 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
Sort each image into the way it should reach the page.
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.
This is how SVGO rounds numbers by default. What does the last line print?
// 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));23.373. toFixed(3) rounds to three decimal places and gives back text, and Number turns it back into a number. SVGO rounds numbers in paths this way by default.
Ravi writes <use href="icons.svg#basket"/>. The code checks whether the sprite has that symbol. What does the last line print?
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]));<use href="icons.svg#cart"/>false. The sprite has no symbol called basket, so this <use> would draw nothing. The basket icon's id is cart.
In the recorded load with PNG files, how many of the eight icons were on the screen at 1,220 ms?
4. The PNG files arrived one by one, so the frames show 1, 2, 3, then 4 icons at 1,220 ms. All eight were there at 1,288 ms.
Open DevTools' Elements panel on this page. Is the first picture, under the opening story, an <img> or an inline <svg>?
An inline <svg>. The lesson's pictures are drawn as SVG inside the page. The frames inside it are <image> elements in that SVG.
Inline, the picture with 1,400 dots adds how many elements to the page?
1,429. The picture's own 28 elements, one group, and 1,400 circles. Every shape in inline SVG is an element on the page. In an <img>, the same picture adds one.
SVGO 4 took two plugins out of its defaults. One is removeViewBox. What is the other?
removeTitle. SVGO 4 took it out of preset-default "to preserve accessibility": a <title> gives a drawing a short text description.
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.
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)JavaScriptconst shorten = (n) => Number(n.toFixed(3)); console.log(shorten(36.6274)); console.log(shorten(6.6274));Choose an answer to see the explanation.
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.
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.
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.
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.
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.
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.
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:
- SVGO, the README, SVGO.
- Preset Default, SVGO.
- convertStyleToAttrs, SVGO.
- removeDesc, SVGO.
- Migration from v3 to v4, SVGO.
- Fabric.js, the Fabric.js project.
- Bootstrap Icons, the README, the Bootstrap authors.
- Including vector graphics in HTML, MDN.
- <use> element, MDN.
- <symbol> element, MDN.
- SVG as an image, MDN.
- <color>, the currentColor keyword, MDN.
- Optimize web fonts, web.dev.
- CSS Fonts Module Level 4, font-display, the W3C.
- font-display, MDN.
- Largest Contentful Paint (LCP), web.dev.
- Measuring SVG rendering time, Stoyan Stefanov.
- PerformanceEventTiming, MDN.
Coming next: Video performance. Posters, preload settings, autoplay, lazy loading, and adaptive streaming for fast video.