Performanceintermediate

CSS & JavaScript Loading Strategies — The Complete Guide

Master resource loading: critical CSS, script loading attributes, preload/prefetch/preconnect, font strategies, and waterfall optimization.

13 min read·Published Apr 10, 2026
performanceloadingcritical-cssfonts

How Browsers Load Resources

Understanding the browser's resource loading pipeline is essential before optimizing it. When a browser receives an HTML document, it parses top to bottom, and each resource it encounters can block or defer rendering.

Browser Loading Pipeline:
+--------------------------------------------------------------------+
| 1. DNS Lookup     | Resolve domain to IP                           |
| 2. TCP Connect    | Establish connection                           |
| 3. TLS Handshake  | Secure the connection (HTTPS)                  |
| 4. HTML Download  | Receive the HTML document                      |
| 5. HTML Parse     | Build DOM, discover resources                  |
| 6. CSS Download   | Fetch stylesheets (RENDER BLOCKING)            |
| 7. CSS Parse      | Build CSSOM                                    |
| 8. JS Download    | Fetch scripts (PARSER BLOCKING by default)     |
| 9. JS Execute     | Run scripts                                    |
| 10. Render Tree   | Combine DOM + CSSOM                            |
| 11. Layout        | Calculate positions and sizes                  |
| 12. Paint         | Draw pixels to screen                          |
+--------------------------------------------------------------------+

Render-Blocking vs Parser-Blocking

Render-blocking (CSS):
  Browser cannot paint until ALL CSS is downloaded and parsed.
  The CSSOM must be complete before the first render.

Parser-blocking (JS without defer/async):
  Browser stops parsing HTML when it hits a <script> tag.
  Must download and execute the script before continuing.

  <html>
    <head>
      <link rel="stylesheet" href="styles.css" />  <!-- Render blocking -->
      <script src="app.js"></script>                <!-- Parser blocking -->
    </head>
    <body>
      <!-- Nothing below renders until both above are done -->
      <h1>Hello</h1>
    </body>
  </html>

Script Loading: defer, async, and module

The three attributes that control how JavaScript loads and executes.

Comparison

Default (no attribute):
HTML:  |===PARSE===|..BLOCKED..|===PARSE===|
JS:                |==DL==|=EX=|
                   ^            ^
                   Parse stops  Parse resumes

async:
HTML:  |========PARSE==========|
JS:       |==DL==|=EX=|
                  ^    ^
                  Parse pauses briefly for execution

defer:
HTML:  |==========PARSE==========|
JS:       |===DL===|              |=EX=|
                                  ^
                                  Executes after HTML is fully parsed

module (type="module"):
HTML:  |==========PARSE==========|
JS:       |===DL===|              |=EX=|
Same as defer, plus: strict mode, own scope, supports import/export

When to Use Each

<!-- DEFAULT: Parser-blocking — avoid this -->
<script src="app.js"></script>

<!-- DEFER: For scripts that need the DOM and must run in order -->
<script src="app.js" defer></script>
<script src="init.js" defer></script>
<!-- app.js runs first, then init.js, both after DOM is ready -->

<!-- ASYNC: For independent scripts (analytics, ads) -->
<script src="analytics.js" async></script>
<script src="tracking.js" async></script>
<!-- Execute order is NOT guaranteed — whichever downloads first runs first -->

<!-- MODULE: Modern scripts with imports -->
<script type="module" src="app.mjs"></script>
<!-- Automatically deferred, strict mode, supports import/export -->
+----------+---------+---------+--------+-----------+
| Attribute| Blocking| Order   | DOM    | Use Case  |
|          |         | Guaranteed| Ready |          |
+----------+---------+---------+--------+-----------+
| (none)   | Yes     | Yes     | No     | Avoid     |
| defer    | No      | Yes     | Yes    | App code  |
| async    | Brief   | No      | No     | Analytics |
| module   | No      | Yes     | Yes    | ESM code  |
+----------+---------+---------+--------+-----------+

Practical Example

<head>
  <!-- Critical CSS inline -->
  <style>/* Critical styles here */</style>

  <!-- Preload important scripts -->
  <link rel="preload" href="/js/app.js" as="script" />

  <!-- Third-party analytics — async (order doesn't matter) -->
  <script src="https://www.googletagmanager.com/gtag/js?id=G-XXX" async></script>

  <!-- App scripts — defer (need DOM, need order) -->
  <script src="/js/vendor.js" defer></script>
  <script src="/js/app.js" defer></script>
</head>

Critical CSS Extraction

Critical CSS is the minimum CSS needed to render above-the-fold content. By inlining it and deferring the rest, you eliminate the render-blocking nature of external stylesheets.

The Problem

Default CSS loading:
Browser requests HTML
  -> Discovers <link rel="stylesheet" href="styles.css">
  -> Requests styles.css (50KB, 200ms)
  -> Waits for full download
  -> Parses CSS
  -> NOW it can render
Time to first paint: TTFB + 200ms CSS download + parse time

With critical CSS:
Browser requests HTML (includes inline critical CSS)
  -> Renders immediately with inlined styles
  -> Loads full CSS in background
Time to first paint: TTFB only

Manual Critical CSS

<head>
  <!-- Inline critical CSS for above-the-fold content -->
  <style>
    /* Only styles needed for initial viewport */
    *,*::before,*::after{box-sizing:border-box}
    body{margin:0;font-family:system-ui,sans-serif;line-height:1.5}
    .header{height:64px;background:#1a1a2e;display:flex;align-items:center;padding:0 1rem}
    .header__logo{color:#fff;font-size:1.25rem;font-weight:700}
    .hero{padding:3rem 1rem;text-align:center}
    .hero__title{font-size:2.5rem;margin:0 0 1rem}
  </style>

  <!-- Load full CSS without blocking render -->
  <link
    rel="preload"
    href="/styles/main.css"
    as="style"
    onload="this.onload=null;this.rel='stylesheet'"
  />
  <!-- Fallback for browsers without JS -->
  <noscript>
    <link rel="stylesheet" href="/styles/main.css" />
  </noscript>
</head>

Automated Critical CSS with critters

// next.config.js — critters is built into Next.js
module.exports = {
  experimental: {
    optimizeCss: true, // Enables automatic critical CSS extraction
  },
};
// For custom webpack setups
// webpack.config.js
const CrittersPlugin = require('critters-webpack-plugin');

module.exports = {
  plugins: [
    new CrittersPlugin({
      // Inline critical CSS and lazy-load the rest
      preload: 'swap',        // Use font-display: swap for fonts
      inlineFonts: false,     // Don't inline font files
      pruneSource: false,     // Keep original CSS file
    }),
  ],
};

Using the critical npm Package

// scripts/extract-critical.js
const critical = require('critical');

async function extractCritical() {
  const { html } = await critical.generate({
    base: './dist',
    src: 'index.html',
    target: 'index-critical.html',
    inline: true,
    width: 1300,
    height: 900,
    // Extract for multiple viewports
    dimensions: [
      { width: 375, height: 667 },   // Mobile
      { width: 768, height: 1024 },  // Tablet
      { width: 1440, height: 900 },  // Desktop
    ],
  });

  console.log('Critical CSS extracted and inlined.');
}

extractCritical();

Preload, Prefetch, and Preconnect

These resource hints tell the browser about resources it will need, allowing it to start fetching them earlier.

preconnect — Warm Up Connections

<!--
  preconnect: Establish connection (DNS + TCP + TLS) early.
  Use for third-party origins you KNOW you'll need.
  Each preconnect costs ~1KB, so don't overdo it (3-5 max).
-->

<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

<!-- CDN for images -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />

<!-- API server -->
<link rel="preconnect" href="https://api.example.com" crossorigin />

<!--
  dns-prefetch: DNS lookup only (cheaper than preconnect).
  Use as fallback or for less critical origins.
-->
<link rel="dns-prefetch" href="https://analytics.example.com" />

preload — Fetch Critical Resources Now

<!--
  preload: "I need this resource on THIS page, fetch it NOW."
  High priority. Must specify 'as' attribute.
  Must be used on the current page — wasted otherwise.
-->

<!-- Preload the LCP image -->
<link rel="preload" href="/hero.webp" as="image" type="image/webp" />

<!-- Preload a critical font -->
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

<!-- Preload a critical script -->
<link rel="preload" href="/js/critical-module.js" as="script" />

<!-- Preload with media query (responsive) -->
<link
  rel="preload"
  href="/hero-mobile.webp"
  as="image"
  media="(max-width: 768px)"
/>
<link
  rel="preload"
  href="/hero-desktop.webp"
  as="image"
  media="(min-width: 769px)"
/>

prefetch — Fetch Resources for Future Navigation

<!--
  prefetch: "The user MIGHT need this resource on a FUTURE page."
  Low priority. Downloads during idle time.
  Great for predictable user flows.
-->

<!-- User is on login page, likely to visit dashboard next -->
<link rel="prefetch" href="/dashboard" />
<link rel="prefetch" href="/js/dashboard-chunk.js" />

<!-- User is reading article 1, likely to read article 2 -->
<link rel="prefetch" href="/articles/2" />

Comparison Table

+--------------+----------+----------+--------------------+------------------+
| Hint         | Priority | When     | Use Case           | Cost             |
+--------------+----------+----------+--------------------+------------------+
| preconnect   | High     | Now      | Known 3rd-party    | ~1KB per origin  |
| dns-prefetch | Low      | Now      | Less critical 3p   | Minimal          |
| preload      | High     | Now      | Critical resources | Must be used     |
| prefetch     | Low      | Idle     | Future navigations | Wasted bandwidth |
|              |          |          |                    | if not visited   |
+--------------+----------+----------+--------------------+------------------+

Programmatic Resource Hints

// Add resource hints dynamically based on user behavior
function prefetchOnHover(url) {
  let link = null;

  return {
    onMouseEnter() {
      link = document.createElement('link');
      link.rel = 'prefetch';
      link.href = url;
      document.head.appendChild(link);
    },
    onMouseLeave() {
      if (link) {
        document.head.removeChild(link);
        link = null;
      }
    },
  };
}

// Usage in React
function NavLink({ href, children }) {
  const handlers = useMemo(() => prefetchOnHover(href), [href]);

  return (
    <a href={href} {...handlers}>
      {children}
    </a>
  );
}

Font Loading Strategies

Web fonts are a common source of render delays and layout shifts. The browser must download fonts before it can render text using them.

The Font Loading Problem

Without optimization:
HTML loads -> CSS parsed -> Font URL discovered -> Font downloads (200ms+)
                                                   Text invisible (FOIT)
                                                   or text shifts (FOUT)

FOIT (Flash of Invisible Text):
|=====BLANK=====|===Styled text===|
                ^
                Font loaded

FOUT (Flash of Unstyled Text):
|===Fallback font===|===Styled text===|
                     ^
                     Font loaded, text reflows (CLS!)

font-display Values

@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');

  /* font-display controls the behavior */
  font-display: swap;
}
+----------+------------------+-------------------+------------------+
| Value    | Block Period      | Swap Period        | Best For        |
+----------+------------------+-------------------+------------------+
| auto     | Browser decides   | Browser decides    | (avoid)         |
| block    | 3s invisible      | Infinite           | Icon fonts      |
| swap     | Minimal (~100ms)  | Infinite           | Body text       |
| fallback | 100ms invisible   | 3s                 | Balanced        |
| optional | 100ms invisible   | None               | Non-essential   |
+----------+------------------+-------------------+------------------+

Recommended:
- Body text: font-display: swap (ensures text is always visible)
- Headings: font-display: fallback (short grace period)
- Non-critical: font-display: optional (may never swap)

Preloading Fonts

<head>
  <!-- Preload critical fonts — they start downloading immediately -->
  <link
    rel="preload"
    href="/fonts/inter-var-latin.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />

  <style>
    @font-face {
      font-family: 'Inter';
      src: url('/fonts/inter-var-latin.woff2') format('woff2');
      font-display: swap;
      font-weight: 100 900;
      /* Subset to only latin characters to reduce size */
      unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
    }
  </style>
</head>

Self-Hosting vs Google Fonts

+-------------------+---------------------------+---------------------------+
| Aspect            | Self-hosted               | Google Fonts CDN          |
+-------------------+---------------------------+---------------------------+
| Extra connection  | No (same origin)          | Yes (fonts.googleapis.com |
|                   |                           |  + fonts.gstatic.com)     |
| Cache             | Your cache headers        | Shared cache (removed in  |
|                   |                           |  Chrome 86+)              |
| Privacy           | No third-party request    | Google sees your users    |
| Control           | Full (subset, format)     | Limited                   |
| Setup             | Manual                    | Copy-paste                |
| Recommendation    | Better performance        | Easier setup              |
+-------------------+---------------------------+---------------------------+
# Self-host Google Fonts using google-webfonts-helper
# Or use fontsource (npm packages)
npm install @fontsource-variable/inter
// Import in your app entry point
import '@fontsource-variable/inter';

// Now use in CSS
// font-family: 'Inter Variable', sans-serif;

Reducing Font CLS with size-adjust

/* Match fallback font metrics to your custom font to eliminate CLS */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
}

/* Adjusted fallback that closely matches Inter's metrics */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.64%;
  ascent-override: 90.49%;
  descent-override: 22.48%;
  line-gap-override: 0%;
}

body {
  /* Browser uses Inter Fallback first, swaps to Inter when loaded */
  /* Because metrics match, there's no visible shift */
  font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}
// Next.js handles this automatically with next/font
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  // Automatically generates size-adjusted fallback
});

export default function Layout({ children }) {
  return (
    <html className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

Network Waterfall Optimization

The network waterfall shows the timeline of all resource requests. Optimizing it means reducing the time between navigation and the last critical resource loading.

Reading a Waterfall

Unoptimized waterfall:
Time:  0ms    200ms   400ms   600ms   800ms   1000ms  1200ms
HTML:  |======|
CSS:           |============|
Font:                        |==========|
JS:            |====================|
Img:                                     |=============|
                                                        ^ First meaningful paint

Optimized waterfall:
Time:  0ms    200ms   400ms   600ms   800ms
HTML:  |======|
CSS:     |=========|     (preloaded, critical inlined)
Font:    |=======|       (preloaded)
JS:         |===========|(deferred)
Img:     |==========|   (preloaded, LCP priority)
                         ^ First meaningful paint (400ms earlier)

Waterfall Optimization Techniques

<head>
  <!-- 1. Preconnect to critical origins FIRST -->
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

  <!-- 2. Inline critical CSS (no network request) -->
  <style>
    body { margin: 0; font-family: system-ui; }
    .hero { min-height: 400px; }
  </style>

  <!-- 3. Preload critical resources in parallel -->
  <link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
  <link rel="preload" href="/hero.webp" as="image" type="image/webp" />
  <link rel="preload" href="/js/app.js" as="script" />

  <!-- 4. Defer non-critical CSS -->
  <link
    rel="preload"
    href="/styles/main.css"
    as="style"
    onload="this.onload=null;this.rel='stylesheet'"
  />

  <!-- 5. Defer all JS -->
  <script src="/js/app.js" defer></script>

  <!-- 6. Async for third-party (lowest priority) -->
  <script src="https://analytics.example.com/track.js" async></script>
</head>

103 Early Hints

// Server sends hints before the full response is ready
// The browser starts downloading resources while waiting for HTML

// Node.js / Express
app.get('/', (req, res) => {
  // Send 103 Early Hints
  res.writeEarlyHints({
    link: [
      '</styles/main.css>; rel=preload; as=style',
      '</fonts/inter.woff2>; rel=preload; as=font; crossorigin',
      '</hero.webp>; rel=preload; as=image',
    ],
  });

  // Then generate and send the full response
  const html = renderPage();
  res.status(200).send(html);
});

// Next.js — configure in next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/',
        headers: [
          {
            key: 'Link',
            value: '</fonts/inter.woff2>; rel=preload; as=font; crossorigin',
          },
        ],
      },
    ];
  },
};

Putting It All Together

Optimized HTML Head Template

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />

  <!-- 1. Preconnect (first — establish connections early) -->
  <link rel="preconnect" href="https://cdn.example.com" crossorigin />
  <link rel="dns-prefetch" href="https://analytics.example.com" />

  <!-- 2. Preload (second — start fetching critical resources) -->
  <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin />
  <link rel="preload" href="/hero.webp" as="image" type="image/webp" />

  <!-- 3. Critical CSS (inline — no network request) -->
  <style>
    :root{--color-bg:#fff;--color-text:#1a1a1a}
    *{box-sizing:border-box;margin:0}
    body{font-family:'Inter',system-ui,sans-serif;color:var(--color-text);background:var(--color-bg)}
    .header{height:64px;display:flex;align-items:center;padding:0 1rem}
    .hero{padding:3rem 1rem;min-height:400px}
  </style>

  <!-- 4. Non-critical CSS (deferred) -->
  <link rel="preload" href="/styles/main.css" as="style"
        onload="this.onload=null;this.rel='stylesheet'" />
  <noscript><link rel="stylesheet" href="/styles/main.css" /></noscript>

  <!-- 5. Font face declarations -->
  <style>
    @font-face{
      font-family:'Inter';
      src:url('/fonts/inter.woff2') format('woff2');
      font-display:swap;
      font-weight:100 900
    }
  </style>

  <!-- 6. App scripts (deferred) -->
  <script src="/js/vendor.js" defer></script>
  <script src="/js/app.js" defer></script>

  <!-- 7. Third-party (async, lowest priority) -->
  <script src="https://www.googletagmanager.com/gtag/js?id=G-XXX" async></script>
</head>

Resource Loading Priority Table

+-----------------------+----------+-------------------------------------+
| Resource              | Priority | Strategy                            |
+-----------------------+----------+-------------------------------------+
| Critical CSS          | Highest  | Inline in <head>                    |
| LCP image             | Highest  | preload + fetchpriority="high"      |
| Primary font          | High     | preload + font-display: swap        |
| App JS                | High     | defer (or preload + defer)          |
| Non-critical CSS      | Medium   | preload as="style" with onload swap |
| Below-fold images     | Low      | loading="lazy"                      |
| Analytics             | Low      | async                               |
| Chat widget           | Lowest   | Load on interaction (facade)        |
| Prefetch next page    | Lowest   | prefetch during idle                |
+-----------------------+----------+-------------------------------------+

Key Takeaways

  • CSS is render-blocking by default — inline critical CSS and defer the rest
  • JavaScript without defer or async is parser-blocking — always use one of them
  • defer preserves execution order and waits for DOM; async executes whenever downloaded
  • preconnect warms connections to known third-party origins (use 3-5 max)
  • preload fetches critical same-page resources earlier in the waterfall
  • prefetch speculatively fetches resources for future navigations
  • font-display: swap prevents invisible text; size-adjust prevents layout shift
  • Self-hosting fonts eliminates third-party connections and gives full control
  • next/font automates font optimization with automatic size-adjusted fallbacks
  • Order matters in <head> — preconnect first, preload second, inline CSS third, defer scripts last
  • 103 Early Hints let the browser start downloading before the HTML response arrives

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles