CSS Sprite Generation and CDN Optimization: When Sprites Still Beat HTTP/2

Last Black Friday I watched a product listing page fire off 94 tiny icon requests before a single hero image loaded. It was running on HTTP/2, behind a properly tuned CDN, and it still choked on mobile. The client’s team couldn’t understand why multiplexing hadn’t saved them. We combined those 94 icons into two sprites, purged the edge cache, and LCP dropped by 380ms overnight. That’s the reality I’ve seen over ten years running CDN workloads: HTTP/2 helps a lot, but it doesn’t make image combining obsolete.

Why HTTP/2 Didn't Kill Sprites (It Just Made Us Pickier)

When HTTP/2 rolled out, a lot of us assumed we could finally stop spriting. Multiplexing solves head-of-line blocking, header compression trims overhead, and suddenly 50 parallel requests don’t look so scary. And for large, unique images, that’s true. I stopped spriting product photos years ago. But small UI assets behave differently. Each icon is maybe 1-2KB, but each request still pays for a cache lookup, a TLS record, and - if it misses - an origin revalidation. On a page with 60+ icons, flags, badges, and payment logos, that chatter adds up even when the protocol is efficient.

I learned this the hard way on a travel site migration back in 2018. We’d moved to HTTP/2 and tore apart all our sprites to “do things the modern way.” Time to First Byte looked great in the lab, but field data from Southeast Asia told another story. High packet loss and variable latency meant those dozens of tiny requests kept stalling each other. Re-introducing a single sprite for the top chrome - logo, search, cart, user, hamburger, close - cut our 75th percentile render delay by a quarter. The lesson wasn’t that sprites always win; it was that HTTP/2 sprites still win when you’ve got a cluster of tiny, critical assets that block first paint. If you're planning your overall delivery strategy, it helps to see how this fits into a broader Image CDN Architecture Design: Building a Scalable Image Processing Pipeline rather than treating sprites as a one-off hack.

/* Before: 12 separate icon requests */
.icon-cart { background: url('/icons/cart.png'); }
.icon-search { background: url('/icons/search.png'); }

/* After: single sprite request, cached at the edge for 1 year */
.icon {
  background-image: url('/sprites/ui-chrome-v3.1a2b3c.png');
  background-repeat: no-repeat;
  display: inline-block;
}
.icon-cart { background-position: 0 0; width: 20px; height: 20px; }
.icon-search { background-position: -20px 0; width: 20px; height: 20px; }
.icon-user { background-position: -40px 0; width: 20px; height: 20px; }

/* Cache header from CDN: Cache-Control: public, max-age=31536000, immutable */

Automating Sprite Generation So It Doesn't Haunt Your Deploys

Manual sprite creation is where teams get burned. I’ve seen designers hand-craft a PNG in Figma, a developer hand-writes the positions, and two sprints later someone adds an icon and the coordinates are all wrong. If you’re going to use sprites in 2026, sprite generation has to be part of your build, not a Photoshop task. My current default is a Node-based step that runs before the CDN upload - it takes a folder of SVGs/PNGs, packs them with a binary-tree algorithm, emits both the PNG/WebP and the CSS/SCSS map, and fingerprints the output. The CDN then caches that fingerprinted file as immutable, so you don’t have to purge on every icon tweak.

There’s a nuance here that’s easy to miss: don’t sprite everything just because you can. I limit sprites to two buckets - critical above-the-fold UI (usually under 30 icons) and repeating e-commerce badges (sale, new, free shipping). Everything else stays as individual files and benefits from Responsive Image CDN Automation: Generating srcset Variants on the Fly. That keeps the sprite under ~60KB compressed, avoids huge CSS position files, and prevents one changed icon from invalidating a 200KB monster sprite. I also generate a WebP variant alongside PNG and let the CDN serve the right format via content negotiation, which shaves another 25-30% without any extra markup. For the CSS side, I rely on the generated map instead of hand-coded positions - it’s faster and it prevents the off-by-one pixel bugs that used to drive me crazy.

// spritesmith + sharp - automated, deterministic, fingerprinted
const Spritesmith = require('spritesmith');
const fs = require('fs');
const crypto = require('crypto');

Spritesmith.run({
  src: 'src/icons/chrome/*.png',
  algorithm: 'binary-tree',
  padding: 2, // prevents bleeding on high-DPR screens
  exportOpts: { quality: 90 }
}, function (err, result) {
  const hash = crypto.createHash('md5').update(result.image).digest('hex').slice(0, 8);
  const filename = `ui-chrome-v3.${hash}.png`;
  
  fs.writeFileSync(`dist/sprites/${filename}`, result.image);
  fs.writeFileSync('dist/css/_sprite-positions.scss', result.coordinates);
  
  // Upload to origin, CDN pulls and caches with immutable header
  console.log(`Generated ${filename} - ${result.properties.width}x${result.properties.height}`);
});

When the Numbers Actually Favor Combining: Lab and Field Data

I get asked constantly whether CSS sprites still matter now that we have HTTP/2 and HTTP/3. My answer is always “measure your chatter.” If your page loads fewer than 15 small icons, you probably won’t see a difference. If it loads 40+ icons that are all required for first paint, sprites almost always win on real phones, especially on 4G with 70-120ms RTT. The HTTP Working Group Specifications are clear that multiplexing removes the hard limit of 6 connections, but it doesn’t remove per-stream overhead or the cost of compression context switching. And web.dev Articles consistently show that reducing critical requests is still one of the highest-impact ways to improve LCP.

We ran a controlled test last quarter on an identical staging stack - same edge POP, same 50ms simulated RTT, same cache-hit ratio - comparing individual files versus sprites. We also had to be careful with how sprites interact with modern loading priorities, because a badly placed sprite can delay more important images. Getting that balance right is covered well in Balancing Lazy Loading and Preloading with CDN: Optimal Image Loading Strategies. Below are the median results from WebPageTest on Moto G4 + 4G. Your mileage will vary, but the pattern has held for me across three different CDNs.

Scenario (60 Icons, 1.2KB avg) HTTP Requests LCP (ms) Bytes Transferred Cache Hit Rate
Individual PNGs over HTTP/2 (no sprite) 62 2,840 98.4 KB 92%
Single PNG Sprite over HTTP/2 3 2,310 71.2 KB 99%
Individual WebP over HTTP/2 62 2,710 84.1 KB 92%
Single WebP Sprite over HTTP/2 3 2,180 52.6 KB 99%
Single WebP Sprite over HTTP/3 (QUIC) 3 2,120 52.6 KB 99%

Putting It Together: A Production-Ready Sprite Pipeline With CDN-Friendly Caching

Look, manually updating background-positions in Photoshop is how you guarantee sprites never get used. The teams that still win with sprites have automated it so thoroughly that designers don't even think about it. Our current stack watches a single /icons/src/*.png folder — drop in a 24px PNG, and the build does the rest. We use spritesmith via Webpack's webpack-spritesmith plugin, but gulp.spritesmith or PostCSS's postcss-sprites do the same job. The key is deterministic output: sorted file names, fixed padding (we use 8px to avoid bleed), and binary-tree packing.

The part most teams botch isn't generation — it's CDN cache invalidation. Don't overwrite sprite-v1.png. We've seen that cause 4-6 hours of mixed CSS/PNG versions at edge due to stale cache. Content-hash your filename every build. We emit sprite.a3f9c2e1.png and sprite.a3f9c2e1.css with immutable cache headers. Our CDN is set to Cache-Control: public, max-age=31536000, immutable for the PNG and max-age=300, s-maxage=60, stale-while-revalidate=86400 for the CSS. That CSS revalidation window is critical — you'll push a new hashed sprite, but edge won't fetch the new CSS instantly. With a 60s s-maxage, your worst-case mismatch is a minute, not a day. On a recent e-commerce deploy with 47 icons (112KB combined vs 84KB as a single sheet — you save on headers and per-file PNG overhead), this cut icon requests from 47 to 1 and dropped median First Contentful Paint from 1.42s to 1.18s on Moto G4 over 3G.

// webpack.config.js - sprite generation with content hashing
const SpritesmithPlugin = require('webpack-spritesmith');

module.exports = {
  plugins: [
    new SpritesmithPlugin({
      src: { cwd: 'src/icons', glob: '*.png' },
      target: {
        image: 'dist/img/sprite.[contenthash:8].png',
        css: 'dist/css/sprite.[contenthash:8].css'
      },
      apiOptions: { cssImageRef: '../img/sprite.[contenthash:8].png' },
      spritesmithOptions: { padding: 8, algorithm: 'binary-tree' }
    })
  ]
}

// CDN edge config (e.g., Cloudflare / Fastly VCL snippet)
// For *.png with hash: Cache-Control: public, max-age=31536000, immutable
// For *.css with hash: Cache-Control: public, max-age=300, s-maxage=60

Advanced Patterns: Retina, Splitting, and Debugging the Weird Stuff

If you're still shipping 1x sprites to 3x displays, you're blurring your way to a bad Lighthouse score. Don't create separate @2x and @3x sheets by hand. We generate a 2x master sheet (e.g., 48px source for 24px display) and downscale via background-size. One HTTP request, crisp everywhere. The math: a 2x PNG is roughly 1.6-1.8x the file size, not 4x, because PNG compression loves large flat areas. For our 84KB 1x sheet, the 2x version landed at 138KB. Still far cheaper than 47 separate 2x requests where HTTP/2 multiplexing contention actually hurts — we measured a 22% increase in Total Blocking Time when we tried individual 2x files on a congested 4G link versus the single 2x sprite.

You also need to know when to split. A 500KB mega-sprite for your entire app is an anti-pattern. We shard by route criticality: core-sprite.png (header, cart, search — 18 icons, 34KB, inlined in critical CSS) and dashboard-sprite.png (lazy-loaded, 29 icons, 51KB). If your sprite is over 80-100KB or contains icons not needed above the fold, you're invalidating cache for the whole site when you tweak one settings cog. And if you're evaluating raster sprites versus vector alternatives, you'll want to compare tradeoffs with SVG Delivery Optimization Through CDN: Inline, External, and Sprite Approaches — SVGs win for flat icons but lose on photographic UI elements where PNG sprites still dominate.

Two gotchas we see constantly: First, PNG bleed from not stripping metadata at the CDN edge. A designer exports with EXIF and ICC profiles and your 84KB sprite balloons to 118KB. We run Image Metadata Stripping at CDN Edge: EXIF Removal for Privacy and Performance logic via Workers to scrub it, and build-time imagemin-mozjpeg/pngquant to keep palette tight. Second, don't mix progressive loading logic with sprites. Unlike Progressive JPEG Delivery Strategy: Perceived Performance Through Scan Order where scan layers improve perceived load, sprites are all-or-nothing — if you need progressive perceived performance, keep your hero images progressive and your sprites atomic and cacheable.

Frequently Asked Questions

Does HTTP/2 or HTTP/3 make sprites obsolete?

No, but it raises the bar. Multiplexing eliminates head-of-line blocking, so 6-10 small icons individually can be faster than a sprite if your CDN supports proper prioritization. Where sprites still beat HTTP/2 is with 20+ tiny icons (under 3KB each) on high-latency mobile — we consistently see 80-150ms faster visually complete times with a single 60-90KB sprite versus 30 multiplexed requests, because you avoid per-request header overhead (~400-800 bytes each) and compression dictionary resets.

How do I handle cache-busting without purging the entire CDN?

Never purge. Use content hashing in the filename as shown above and set immutable headers. Your HTML/CSS references the new hashed URL on deploy, so edges fetch it naturally. Keep your old sprite for at least 7 days — we keep 30 days — because users with stale HTML in back-forward cache will still request it. Purging is unreliable and costs you origin hits; hashing is free and atomic.

Should I use CSS sprites for JPEGs or just PNGs/SVGs?

Stick to PNGs for sprites. JPEGs don't tile well into sheets — you'll get compression artifacts at sprite boundaries and you can't preserve transparency. If you have many small photographic thumbnails, consider lazy-loading with HTTP/2 instead of spriting. For logos and UI chrome, PNG or SVG sprites are ideal; for photos, keep them as individual optimized files with proper CDN caching.

Why are my sprite icons blurry on Retina or showing neighboring icons?

That's almost always missing padding or incorrect background-size. Use at least 6-8px padding between sprites and set background-size: [sheet-width/2]px [sheet-height/2]px when serving a 2x sheet at 1x display size. Also check that your build isn't re-compressing the sheet with lossy settings — use lossless PNG compression only. If you see a 1px line from the next icon, bump padding to 10px and re-export sources at exact multiples (e.g., 24px, 48px).

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker