Arsanyos Asrat Emiru

The CSS Mistake Costing Us 700ms of Load Time (And How It Got Fixed)

Arsanyos Asrat6 min read

Eliminating render-blocking CSS boosted our site's performance by 0.7s. A deep dive into build-time strategies that move beyond simple preload hacks to surgical CSS extraction.

Originally published on LinkedIn

View original post →

The CSS Mistake Costing Us 700ms of Load Time (And How It Got Fixed)

When my senior dev asked me to run a Lighthouse audit, I discovered 'Render Blocking Resources' were stealing over a second of load time. The culprit? Our CSS was holding the initial paint hostage.

For those unfamiliar, "render-blocking CSS" is exactly what it sounds like: the browser must stop parsing HTML, download the CSS, and parse it completely before it can paint anything to the screen. This mandatory waiting period directly delays your First Contentful Paint (FCP), one of the most critical user experience metrics.

I had previously used preload technique coupled with the surgical removal of injected link tags during vite builds process —a common workaround that changes the loading priority. While they helped, they never felt like a true solution, more of a hack that treated the symptom, not the disease.

<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>

I wanted a more robust, build-time approach that would surgically separate critical from non-critical CSS and eliminate the problem at its root.

This led me to develop a Vite plugin that automatically strips all stylesheet links post-bundling and a strategy to inline only the essential styles for the initial paint. The result? We achieved what every frontend engineer dreams of: a 0.7-second (700ms) reduction in First Contentful Paint. Here's how we turned a complex optimization into tangible user experience gains.


Part 1: A Build-Time Approach to Eliminate Render-Blocking Resources in Bundlers

The Advanced Strategy - Surgical CSS Extraction & Inlining

1. The Core Idea: Separation of Concerns

The goal is to split CSS into two categories:

  1. Critical CSS: The minimal set of styles required to render the "above-the-fold" content. This must be delivered immediately, in the HTML itself, to avoid a round trip to the server.
  2. Non-Critical CSS: All other styles (e.g., styles for components below the fold, modals, complex interactive states). These can be loaded asynchronously after the main content has painted.

2. Step 1: The "Prevent Injection" Plugin - Removing the Blocking Tags

This plugin runs after Vite has processed and injected all the <link> tags into your final index.html. Its job is to find all those tags and remove them completely. So what I am telling vite here is "Do your normal bundling, but I'm going to handle how the CSS gets loaded myself"

The plugin hooks into two stages:

  1. transformIndexHtml: As a secondary measure to ensure they're gone.
  2. generateBundle: To remove CSS links from the final built HTML files.
// vite.config.js
export default defineConfig({
  const preventCSSInjection = () => {
    return {
      name: 'prevent-css-injection',
      generateBundle(options, bundle) {
        Object.keys(bundle).forEach((fileName) => {
          const file = bundle[fileName];
          if (file.type === 'asset' && fileName.endsWith('.html')) {
            let html = file.source;
            html = html.replace(/]*rel="stylesheet"[^>]*>/g, '');
            html = html.replace(/]*href="[^"]*\.css"[^>]*>/g, '');
            file.source = html;
          }
        });
      },
      transformIndexHtml: {
        order: 'post',
        handler(html) {
          return html.replace(/]*rel="stylesheet"[^>]*>/g, '');
        },
      },
    };
  };

  plugins: [
    // ... other plugins
    preventCSSInjection(), // the custom plugin
  ],
})

3. Step 2: Strategic Chunking - Organizing the CSS

The manualChunks configuration ensures the CSS is bundled predictably.

This creates separate CSS files you can reference later, like global-[hash].css and styles-[hash].css in build output.

// vite.config.js
rollupOptions: {
  output: {
    manualChunks(id) {
      // CSS-specific logic
      if (id.includes('.css')) {
        if (id.includes('global')) {
          return 'global'; // Creates a 'global.css' chunk
        }
        return 'styles'; // Creates a 'styles.css' chunk for other CSS
      }
    },
  },
}

4. Step 3: Inlining Critical CSS - The First Paint

Since we removed all CSS links, the page would be unstyled. We solve this by manually inlining the absolute minimum CSS required for the initial render directly inside a <style> tag in your index.html.

5. The Missing Piece: Loading The Non-Critical CSS

We've removed the blocking tags and inlined the critical stuff. Now, how do we load the rest of CSS (e.g., global.css, styles.css) without blocking the render?

You would need a final step. The most common and effective pattern is to use an asynchronous CSS loader in a script tag at the end of your index.html body.

Here is a step by step guide for tackling this:

  1. Update package.json build commands
  2. Create a Script to Read the Manifest and inject script Tag at the end of your index.html in your <body> tag with the correct hashed filename into the HTML
  3. We enable build.manifest in vite.config.js (NB: if you have PWA configurations the default name for manifest generated by vite might get overridden so make sure to give it a deliberate name)
// scripts/inject-css.mjs
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const manifestPath = path.resolve(__dirname, '../dist/vite-manifest.json');
const indexPath = path.resolve(__dirname, '../dist/index.html');

// Check if files exist first
if (!fs.existsSync(manifestPath)) {
  throw new Error(`Manifest file not found at: ${manifestPath}`);
}

if (!fs.existsSync(indexPath)) {
  throw new Error(`Index HTML file not found at: ${indexPath}`);
}

const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));

// 1. Find the hashed CSS filenames from the manifest
const cssFiles = Object.entries(manifest)
  .filter(([key, value]) => value.file && value.file.endsWith('.css'))
  .map(([key, value]) => value.file);

if (cssFiles.length === 0) {
  throw new Error('No CSS files found in manifest!');
}

// 2. Create the script tag with the correct paths
const cssLoaderScript = `
<script>
function loadCSS(href) {
  var link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = href;
  document.head.appendChild(link);
}

// Load your non-critical CSS files asynchronously
${cssFiles.map((file) => `loadCSS('/${file}');`).join('\n')}
</script>
`;

// 3. Read the built HTML and inject the script before the closing </body> tag
let html = fs.readFileSync(indexPath, 'utf8');
html = html.replace('</body>', `${cssLoaderScript}</body>`);

// 4. Write the modified HTML back to disk
fs.writeFileSync(indexPath, html, 'utf8');

console.log('✅ Successfully injected CSS loader script into index.html');
console.log('📦 CSS files loaded:', cssFiles);
// package.json
{
  "scripts": {
    "build": "vite build && node scripts/inject-css-script.mjs"
  }
}

Why Choose This Approach Over Simple Preload?

As Kevin Malone from The Office said: "Why waste time say lot word when few word do trick".

Masterpiece Approach Benefits:

  1. Truly eliminates render-blocking by separating critical from non-critical CSS and loading them at different times.
  2. Better caching strategy: Critical CSS is small and inline (no cache), while non-critical CSS can be cached aggressively. If you update only your main styles, the critical CSS (which rarely changes) doesn't bust the cache.

Preload Approach Limitations:

  1. Doesn't actually eliminate render-blocking. It just changes the loading priority. The CSS is still loaded synchronously and applied all at once.
  2. All CSS is cached as one unit.

This build-time approach is a significant upgrade over simple preload hacks I used before. By surgically removing all render-blocking <link> tags with a custom plugin and inlining only the essential critical CSS, we guarantee the fastest possible First Contentful Paint score contribution. The non-critical styles are then loaded asynchronously, completing the visual experience without delaying interactivity.

This method requires more setup but delivers superior performance results by addressing the problem at its root—during the bundle process.


Thank you for reading -- አመሰግናለሁ

A

Arsanyos Asrat

Frontend Engineer | Performance Optimization Specialist