How to Improve Loading Times in Web Applications (website speed)
Table of Contents
- Introduction: Why Asset Loading Matters
- How the Browser Loads a Page
- Resource Hints and Prioritization
- Loading JavaScript: Blocking Scripts to ES Modules
- The Network Layer: HTTP/2, HTTP/3, Early Hints, and Compression
- Images, Fonts, and Media
- Loading CSS Without Blocking Render
- Architecture-Level Loading Strategies
- Framework Differences: Next.js, React, Vue, and Svelte
- Measuring and Debugging Loading Performance
Hire Runastartup to build, optimize or scale your web app!
1. Introduction: Why Asset Loading Matters
Every web page, from the simplest blog to the most complex dashboard, is assembled at load time from many individual files. These files are collectively called assets: HTML documents, JavaScript files and modules, stylesheets, images, videos, fonts, JSON data, and third-party scripts. The strategy you use to deliver those assets determines how quickly your page becomes visible to users, how quickly it becomes interactive, and how smooth interaction becomes.
For most of the web’s history, loading asset files was an afterthought. You wrote a <script src="..."> tag, the browser downloaded the file, and that was that. But as applications grew more complex and JavaScript bundles grew from kilobytes to megabytes, the way assets are requested, prioritized, parsed, and executed became one of the most important levers in web performance. A page that ships two megabytes of blocking JavaScript can feel sluggish even on a fast phone with a fast connection, because download speed is only one part of the equation. The browser also has to parse, compile, and execute that code before the page responds to input.
The industry measures this with Core Web Vitals, Google’s set of user-centric performance metrics that affect both user experience and, importantly, search ranking:
- Largest Contentful Paint (LCP) – how long until the largest visible element (often a hero image or a headline) has rendered. Good is under 2.5 seconds. Loading strategy directly affects LCP: a hero image that is discovered late, or a font that blocks text rendering, will increase LCP.
- Interaction to Next Paint (INP) – how quickly the page responds to user interactions (clicks, taps, key presses). <200 milliseconds is considered good. A huge JavaScript bundle that takes two seconds to parse and execute will make early interactions feel dead, because the event handlers haven’t been attached yet.
- Cumulative Layout Shift (CLS) – how much visible content jumps around while loading. Good is under 0.1. Loading strategy affects CLS when images without dimensions load late and push content around, or when a web font swaps in and changes text metrics.
Modern asset loading strategy is about the following 3 goals:
- Minimize the critical path – get the resources needed for first render to the browser as early and as prioritized as possible.
- Reduce render-blocking resources – make sure non-essential JavaScript and CSS do not stand between the user and a painted page.
- Defer everything the user doesn’t need yet – load below-the-fold images, rarely-used components, and third-party widgets only when appropriate (on demand, during idle time, or just before they’re predicted to be needed).
These goals are achieved through a layered set of tools: browser-level primitives like async, defer, ES modules, and resource hints (preload, prefetch, preconnect, modulepreload, fetchpriority); newer platform features like the Speculation Rules API and 103 Early Hints; transport-level improvements like HTTP/3 and Brotli/Zstandard compression; and framework-level abstractions in Next.js, React, Vue/Nuxt, and Svelte/SvelteKit, which wrap these primitives in ergonomic APIs.
This article walks through every layer, from first principles to framework specifics.
2. How the Browser Loads a Page
Before we can optimize loading, we need to understand what the browser actually does between “user presses Enter” and “page is interactive.” Understanding this pipeline is what separates developers who paste performance tips from Stack Overflow from developers who can reason about loading from first principles.
When the browser receives the HTML document, it begins parsing it: reading tags top to bottom and building the Document Object Model (DOM), the tree of nodes that represents your page’s structure. In parallel, whenever the parser encounters a stylesheet, it fetches it and builds the CSSOM, the tree that represents your styles. Neither rendering nor JavaScript execution can meaningfully proceed without these trees, which is why they are called the critical rendering path: DOM → CSSOM → render tree → layout → paint.
- Synchronous (classic) scripts. When the parser hits
<script src="app.js"></script>withoutasyncordefer, it stops building the DOM, downloads the script, executes it, and only then continues. This is parser-blocking behavior and it exists because the script might usedocument.writeor otherwise modify the document that follows. - Stylesheets (in specific circumstances). CSS blocks rendering (you never want to show unstyled content), and if a script follows a stylesheet, the script’s execution waits for the CSS too, because the script might query computed styles.
Every blocking script on the critical path adds a round trip: the parser must discover the script, download it, and run it before continuing. On a slow connection with high latency, each round trip is expensive. This is the critical path length, and minimizing it is objective number one.
The Preload Scanner
Here is a piece of browser engineering that saves the web daily: while the main parser is blocked on a script, a second, lightweight scanner (Chrome calls it the preload scanner) races ahead through the remaining raw HTML text looking for resources src of images and scripts, href of stylesheets, etc. and starts downloading them in the background. This is why simply listing your resources early in the HTML helps even when scripts block execution: discovery is not blocked, only parsing and execution.
The preload scanner cannot see resources that are only referenced inside JavaScript strings (for example, an image URL constructed at runtime, or a chunk name inside a bundler’s lazy-loading code). It cannot know that a <div> will soon become a carousel that needs a 400 KB script. The entire family of resource hints we will cover in the next section exists to give the browser information the preload scanner cannot infer on its own.
The Network Waterfall and Priorities
When the browser requests resources, it does not treat them equally. It assigns each request a priority based on its type and location in the document: render-blocking CSS gets very high priority; an <img> far down the page gets low priority; scripts vary depending on whether they are blocking, async, defer, or module scripts; a fetch() initiated by JavaScript starts at a low “fetch” priority by default. You can see these priorities in Chrome DevTools’ Network panel (right-click the column headers and enable “Priority”), and the resulting staggered chart of requests is the network waterfall.
Reading waterfalls and common pathological patterns include:
- Long gaps between requests – a resource was discovered late (fix: preload it earlier or hint it).
- A wide, tall bar blocking everything after it – a parser-blocking script (fix:
defer,async, or modules). - The LCP image starting late – usually because it was discovered by JavaScript or is buried in CSS (fix:
<link rel="preload">orfetchpriority="high"). - Dozens of low-priority requests competing at the same time; the browser multiplexes them, but they still share bandwidth; consider bundling, trimming, or deferring.
Latency, Bandwidth, and Round Trips
Two different physical constraints govern transfer time. Bandwidth is how many bits per second the connection carries — important for large files. Latency (round-trip time) is how long a single request takes to make the trip — important when many dependent requests chain. For loading strategy, latency is usually the bigger enemy: a page that needs six sequential round trips to discover and fetch its critical resources will be slow even on gigabit fiber, and doubly slow on mobile networks where round trips cost 100+ ms each.
This is why so many modern techniques (preconnect, preload, Early Hints, HTTP/3, Speculation Rules) are fundamentally about starting requests earlier or removing round trips, rather than making files smaller. Both matter, but the ordering and prioritization of requests is where the biggest wins usually hide.
3. Resource Hints and Prioritization
Resource hints are <link> elements that tell the browser about resources it wouldn’t otherwise know about, or know early enough. They are hints in the literal sense: the browser is free to ignore them if it judges them wasteful (except preload, which is mandatory). Used carefully, they are the highest-leverage, lowest-effort performance tool available. Used carelessly, they actively harm performance by competing with genuinely critical resources.
Warming Connections: dns-prefetch and preconnect
Before the browser can request any file from a third-party origin or CDN, it must resolve DNS, open a TCP connection, and negotiate TLS. Each step is a round trip, and the full handshake for a new origin commonly costs 100–500 ms, sometimes more on mobile. 2 Link tags address this:
<!-- Resolve DNS only -->
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- Resolve DNS + open TCP + negotiate TLS -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>dns-prefetch is the lighter of the two and is widely supported, including in browsers that don’t support preconnect. The crossorigin attribute matters for preconnect. If you will fetch resources via CORS (fonts, fetch() with credentials modes), open the CORS connection; if not, leave it of. A mismatch forces the browser to open a second connection, defeating the purpose.
Practical guidance: preconnect only to origins you are certain you will use within the first seconds (your asset CDN, your font host, your API). 2-3 preconnects is a sensible amount; each one costs memory and a socket, and unused preconnects are pure waste. Place them at the very top of <head> so they start before anything else.
Fetching Critical Resources with preload:
<link rel="preload"> tells the browser: “fetch this resource immediately with high priority, because the current page will need it to function”. Preload is a directive the browser obeys. Its purpose is getting resources into the network earlier than the parser or preload scanner would normally find them:
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<link rel="preload" href="/critical-chunk.js" as="script">Rules for using preload correctly:
- Set
as(font, style, script, image, fetch, document, etc) It determines priority, caching, and theAcceptheaders sent. A preload without a correctasmay be double-fetched. - Fonts need
crossorigineven when hosted on your own origin, because fonts are always fetched in CORS mode. This is the single most common preload bug; without it. - Preload is for the current page. Preloading a resource used by the next page is what
prefetchis for. - Match URLs exactly including query strings or the browser will fetch twice; once preloaded, once for real.
Caution: A preload that is never used is worse than no preload: it consumed bandwidth that critical resources needed. Chrome will warn you in the console. Treat that warning seriously.
Preloading for ES Modules with modulepreload:
<link rel="modulepreload" href="/js/app.js">A plain preload as="script" fetches the bytes of a module file and stops there. modulepreload fetches the module and recursively fetches its static import dependencies, then places all of them in the browser’s module map with their parse and compile work done. When the actual <script type="module" src="app.js"> executes, the module and its dependency graph are already downloaded, parsed, and compiled reducing compilation time. This can be can be substantial for for large module graphs.
Build tools like Vite use this heavily, for example, it automatically emits a modulepreload link for every chunk in a route’s dependency graph when you lazy-load routes. You will rarely write these by hand in a bundled app, but understanding what your bundler emits is a very useful performance audit.
Loading for the Next Navigation with prefetch:
Prefetch asks the browser to fetch a resource during idle time at low priority, for later use by the next page the user visits:
<link rel="prefetch" href="/next-article-page-2.html">
<link rel="prefetch" href="/js/pricing-section.chunk.js" as="script">Prefetched resources land in the HTTP cache subject to cache headers. If the user navigates to the page that needs them, they are already on disk. If they don’t, the bytes were wasted so prefetch is a bet you place with the user’s behavior. Prefetching the next page in a paginated flow, or a chunk for a dialog that 80% of users open, are good bets. Prefetching your entire app is not.
Note that prefetch is increasingly superseded by the Speculation Rules API for whole-page navigations, which does the same job with better targeting and in prerender mode for more aggressive preparation.
Steering Priority Without Forcing Downloads with fetchpriority:
The fetchpriority attribute (high, low, or auto) is the newer prioritization primitive. However, it is different from everything above and it does not cause a fetch. It adjusts the priority of a fetch that will happen anyway, wherever the resource is declared:
<!-- The hero image is in the HTML, but the browser gave it low priority
because it hasn't measured layout yet. Steer it up: -->
<img src="/hero.webp" fetchpriority="high" width="1600" height="900" alt="...">
<!-- Many below-the-fold images that don't need to compete: -->
<img src="/gallery-14.webp" fetchpriority="low" loading="lazy" ...>
<!-- Or with preload: -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">The canonical use case is the LCP image. Browsers assign images a relatively low initial priority so a hero image often starts downloading later than it should. Marking it fetchpriority="high" typically starts it one to two waterfall rows earlier, a direct LCP win. The inverse is equally useful marking decorative far-down-page images fetchpriority="low" keeps them from competing with the hero.
fetchpriority is now supported across Chromium browsers, Firefox, and Safari, making it safe to use as a progressive enhancement; unsupported browsers simply ignore it.
The Speculation Rules API: Prefetch and Prerender for Navigation
The Speculation Rules API is the modern replacement for <link rel="prerender"> (deprecated) and a strict upgrade over prefetch for navigations. Instead of hinting at single resources, you declare JSON rules that let the browser prefetch or fully prerender entire pages it predicts the user will visit:
<script type="speculationrules">
{
"prefetch": [{
"where": "href_matches=/docs/*",
"eagerness": "moderate"
}],
"prerender": [{
"where": "href_matches=/blog/*",
"eagerness": "moderate"
}]
}
</script>prefetchrules fetch the page’s HTML at low priority, so the subsequent navigation needs no round trip.prerenderrules go much further: the page is rendered in a hidden state — HTML parsed, scripts executed, layout and paint performed — so that when the user clicks, the page appears instantly. Certain side-effectful APIs (cookies, analytics, notifications) are deliberately delayed until activation, and the page receives aprerenderingchangeevent when it goes live.eagernesscontrols the bet:conservativereacts only to explicit triggers,moderateprefetches links the user has been near or hovered briefly, andeagerspeculates on all matching links like pagination.
As of 2025–2026, Speculation Rules work in Chromium browsers; Firefox and Safari do not implement them and simply ignore the rules, which makes the API a pure progressive enhancement. Real-world adoption has been significant including WordPress >6.8 shipped speculative loading enabled by default**, instantly covering a large fraction of the web, and Shopify documented production usage.
A Mental Model for Choosing Hints
Beginners often ask which hint to use when. A useful decision sequence:
- Is the resource needed by the current page, but discovered too late? →
preload(+fetchpriority="high"if it is LCP-critical). - Is it a module graph needed by the current page? →
modulepreload(usually emitted by your bundler). - Is it needed by a likely next navigation? →
prefetchor, better, Speculation Rules. - Is it on a third-party origin you’ll definitely use? →
preconnect. - Do you merely want to change priority of something already discoverable? →
fetchpriorityalone.
4. Loading JavaScript: Blocking Scripts to ES Modules
JavaScript is the most performance-sensitive asset your page loads, because unlike images and CSS, it must be parsed, compiled, and executed before it does anything, and because executing it competes with the very same main thread that must paint the page and respond to input. This section covers the script-loading mechanics.
Classic Scripts: Default, async and defer
The three classic loading modes are best understood as answers to two questions:
Does the download block HTML parsing?
When does the script execute?
| Mode | Download | Execution | Execution order |
|---|---|---|---|
<script src> | Blocks parsing while downloading | Immediately when ready | Document order |
<script async src> | Parallel with parsing | ASAP when downloaded — pauses parsing to run | Whenever ready (unordered) |
<script defer src> | Parallel with parsing | After parsing, before DOMContentLoaded, in document order | Document order |
Default is the legacy behavior and not what you typically want in the document <head> these days because it stalls DOM loading for the entire page build time.
async is for independent scripts that don’t care about the DOM or other scripts: analytics beacons, ad snippets, A/B testing loaders. Two async scripts that depend on each other encounter race conditions.
defer is the workhorse for application scripts: they download in parallel, execute in order, and are guaranteed to run after the full HTML has been parsed so document.querySelector finds everything. For a traditional multi-script page, defer preserves your dependency order while removing parser blocking.
Note that async and defer only remove the download block from parsing. The execution still happens on the main thread and still costs time. A 1 MB “deferred” script still delays DOMContentLoaded and interactivity by its parse/compile/execute time. Deferral moves the problem; it does not eliminate it — only shipping less JavaScript, or splitting it into lazy chunks, eliminates it.
ES Modules type="module" and Native Dependencies
Native ES modules (<script type="module">) change the loading model from “files” to “graphs”:
<script type="module">
import { formatPrice } from '/js/utils.js'; // fetched, parsed, compiled by the browser
console.log(formatPrice(1999)); // runs in strict mode, module scope
</script>Key properties of module scripts:
Deferred by default. A module in the head tag behaves like defer: it downloads in parallel and executes after parsing, in document order.
The browser resolves the graph. When app.js imports utils.js which imports format.js, the browser fetches each file and builds the dependency tree before executing anything. This is why modules pair so well with modulepreload (section 3.3): without it, each dependency level can cost another discovery round trip.
Modules execute once. Import the same module from ten places; it is fetched, parsed, and evaluated exactly once, and everyone shares the result.
Strict mode, module scope, static structure. No globals leak implicitly, this at top level is undefined, and imports/exports must be statically analyzable which is what enables tree-shaking in bundlers.
Top-level await is allowed: a module can await at its root, and importers (and dependent module execution) wait for it to settle.
CORS required. Modules must be served with proper CORS headers when cross-origin and importantly for local testing, they generally do not load from file:// URLs in Chromium; you need a local server.
In production, most teams bundle modules rather than shipping the raw graph: bundling enables tree-shaking eliminating unused exports, minification, and content-hashed filenames for long-term caching. But the module format is the common language both sides speak, your bundler consumes ESM, and your emitted chunks are ESM. Notably, Safari 27 (2026) shipped a rewritten module loader with improved top-level-await and import-ordering conformance, continuing the platform’s investment in native modules.
Dynamic import(): Code Splitting and On-Demand Loading
Static import statements load everything in the graph before anything runs. Dynamic import() turns module loading into an asynchronous, runtime-controlled operation:
const { openChart } = await import('/js/chart.js'); // returns a Promise
openChart(canvas);Dynamic import is the foundation of code splitting: you declare split points, and the code behind them is fetched only when the import actually executes. A settings dialog used by 5% of users, a charting library needed on one dashboard tab, a heavy markdown parser needed only in the editor route. All can be split out of the critical bundle and loaded on interaction, on route change, or during idle time. Every framework’s lazy-loading API (next/dynamic, React.lazy, defineAsyncComponent, SvelteKit route splitting) compiles down to dynamic import() plus framework-specific bookkeeping.
Strategies when to trigger the split-point import:
- On interaction – load when the user clicks the button/tab
- On visibility – load when the component scrolls near the viewport (via
IntersectionObserver). - On idle/hover prefetch – load during
requestIdleCallbackor on link hover/pointerdown, so the chunk is warm before the user commits. This is the strategy GitHub popularized for its SPA navigation, and it is whatnext/link,NuxtLink, and SvelteKit preloading automate.
Import Maps: Controlling Module Resolution
Import maps let a page control how bare specifiers resolve import _ from "lodash" inside native modules:
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.example.com/lodash@4.17.21/es/lodash.js",
"mylib/": "/js/vendor/mylib/"
}
</script>
<script type="module">
import { debounce } from 'lodash'; // resolved by the map
import { helper } from 'mylib/helpers.js'; // prefix mapping
</script>Before import maps, bare specifiers only worked inside bundlers; in the browser you needed full paths everywhere. Import maps give unbundled or lightly-bundled apps a clean dependency layer: pin versions, map to a CDN or to local files, provide fallbacks, and (via multiple scopes) resolve different versions of the same library for different parts of the app. Import maps are baseline-supported across all major browsers since 2023–2024 and are increasingly used for dependency-free prototypes, micro-frontends sharing one dependency copy, and progressively enhanced multi-page apps that want modules without a build step.
The Newer Module Features: import defer and Import Attributes
Two recent ESM extensions are worth knowing as the platform’s direction of travel:
import defer (deferred module evaluation) lets a page download and link a module graph without evaluating it until first use:
import defer * as heavy from './heavy-widget.js';
// ... later, evaluation happens exactly once, on demand:
heavy.render(container);The goal is to move expensive module evaluation running top-level code off the critical path while keeping the dependency downloaded and linked for instant availability. Browser support is still rolling out so treat it as progressive enhancement.
Import attributes declare how a non-JavaScript module should be handled:
import config from './config.json' with { type: 'json' };
import wasmModule from './crypto.wasm' with { type: 'webassembly' };JSON modules are the practical one today: they work in all modern browsers with the explicit with { type: 'json' } marker (the older assert keyword is deprecated). This enables config-as-a-module without bundler transforms.
Bundling in the Modern Era
Let’s present a natural question: “If browsers support modules natively, do we still need bundlers like Webpack, Vite, Rolldown, and Turbopack?” For production apps, yes, here’s why:
- Tree-shaking and minification bundlers eliminate dead code paths and minify identifiers; native module serving does neither.
- Caching granularity content-hashed chunk filenames (
app-a3f9c2.js) let returning users skip unchanged code entirely. A change to one small utility invalidates only the chunk containing it, not 40 files. - Waterfall control deep native import graphs can chain round trips (A discovers B, B discovers C); bundlers flatten the graph.
- Transforms TypeScript, JSX, and modern syntax targeting all flow through the bundler.
The modern workflow uses the best of both: unbundled native ESM in development. Vite’s approach is instant startup, on-demand transformation, near-instant HMR and bundled, split, hashed chunks in production. Next-generation Rust-based tooling (Turbopack for Next.js; Rolldown and oxc in the Vite ecosystem) has made both modes dramatically faster to build, but the output strategy has stayed stable, and that’s the part your users feel.
5. The Network Layer: HTTP/2, HTTP/3, Early Hints, and Compression
Loading strategy depends on the transport protocol beneath it. You don’t need to implement these layers, but you do need to make decisions (headers, CDN settings, server configuration) that use them well.
From HTTP/1.1 to HTTP/3
HTTP/1.1 allowed roughly six parallel connections per origin, forcing browsers to domain-shard and bundle aggressively.
HTTP/2 (2015) introduced multiplexing: many requests share one connection, each request/response split into interleaved frames. Domain sharding and sprite sheets became anti-patterns overnight; small, many files became cheap which made unbundled development servers practical.
HTTP/3 runs over QUIC (UDP-based) and removes HTTP/2’s remaining head-of-line blocking. On its shared TCP connection, one lost packet stalls all streams, because TCP guarantees ordered delivery.
QUIC implements reliability per-stream so a loss on stream 3 doesn’t block streams 1 or 2.
QUIC folds the TLS handshake into connection setup, cutting a round trip versus TCP+TLS. For real users on lossy mobile networks, HTTP/3 measurably improves time-to-first-byte and resilience. Adoption crossed roughly a third of site loads by early 2026 and continues to grow.
One HTTP/2 feature became deprecated: Server Push (link: <...>; rel=preload pushed proactively by the server). It proved nearly impossible to target accurately. Resources frequently removed cache entries the user needed. Its successful replacements are: 103 Early Hints and preload, which let the browser decide what to fetch.
103 Early Hints
Early Hints is the platform’s answer to “the server is slow to generate the HTML, but we know what the HTML will reference.” A supporting server sends an informational 103 response with Link headers before the final 200:
HTTP/1.1 103 Early Hints
Link: </fonts/inter-var.woff2>; rel=preload; as=font; crossorigin
Link: </css/app.css>; rel=preload; as=style
Link: </cdn.example.com>; rel=preconnect
HTTP/1.1 200 OK
...actual HTML follows after backend processing...The browser starts preloading and preconnecting while the backend finishes rendering the document, turning wasted waiting time into load time. An SSR app whose server takes 300–800ms to render HTML, with a CDN, terminating Early Hints from cached Link headers. For a Nextjs-style app, Vercel emits Early Hints automatically from the page’s static analysis.
Compression: Gzip → Brotli → Zstandard
Text assets (HTML, CSS, JS, JSON, SVG) should never be served uncompressed.
–Gzip is the universal baseline.
–Brotli (br) compresses 15–25% better for static text at similar decompression speed and is supported everywhere that matters.
–Zstandard is the emerging next step.
It has even better ratio/speed tradeoffs and (notably) shared dictionaries compression seeded with your framework’s runtime strings, reducing framework code by an extra 60–80% after the first load. Rules of thumb: Brotli at maximum level for precompressed static files; dynamic streaming compression at lower levels; ensure your server/CDN’s Accept-Encoding negotiation includes br (and zstd where offered).
Caching: The Fastest Request Is No Request
No loading strategy beats serving from cache. The production pattern for hashed, immutable assets:
Cache-Control: public, max-age=31536000, immutablefor files like app-a3f9c2.js whose names change with content, and no-cache (revalidate) for the HTML document itself so users pick up new hashes immediately. Mis-set cache headers silently destroy every other optimization: a returning user who re-downloads an unchanged 800 KB bundle because max-age=3600 expired pays the full cost again. Audit this in the Network panel’s “Size” column “(memory cache)” and “(disk cache)” mean success; a full download means a caching bug.
6. Images, Fonts, and Media
Images tend to be the largest byte size on a page thus making the LCP element most frequently an image. Fonts are small but uniquely able to delay text rendering. Both have dedicated loading machinery.
Responsive, Modern-Format Images
Two attributes make images responsive without JavaScript:
<img src="photo-800.webp"
srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 50vw"
width="1600" height="900"
alt="Team standup"
loading="lazy" decoding="async">srcset lists candidate files with their intrinsic widths; sizes tells the browser how wide the image will display under which conditions. The browser then picks the smallest adequate file; phone gets the 400w file, a retina laptop the 1600w one. This is a download-size win you get for free, with no JS.
Serve AVIF or WebP 30–50% smaller than JPEG/PNG at equivalent quality. Most CDNs and image components like next/image do conversions automatically.
Always set width and height or CSS aspect-ratio. The browser reserves space before the file arrives, preventing layout shift (CLS) making it the easiest CLS fix in existence.
Lazy Loading
<img src="footer-banner.webp" loading="lazy" decoding="async" alt="...">
<video src="promo.mp4" preload="none" muted playsinline></video>loading="lazy" defers off-screen media until it approaches the viewport; decoding="async" lets image decoding happen off the main thread. Do not lazy-load the LCP/hero image. Lazy-loading a hero image forces the browser to wait for layout before it even starts the request.
The pattern to internalize: eager + high priority for above-the-fold, lazy + low priority for the rest.
The better hero image recipe is:
<link rel="preload" href="/hero.avif" as="image" fetchpriority="high">
...
<img src="/hero.avif" fetchpriority="high" width="1600" height="900"
alt="..." decoding="async">Either the fetchpriority="high" on the img tag or the rel="preload" when the image is only referenced from CSS or JS) starts the download at high priority at discovery time. This combination is one of the most reliable LCP optimizations in practice.
Fonts are The Invisible Render Block
Web fonts block the text they style from rendering and cause it to flash/swap. The loading strategy is:
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<style>
@font-face {
font-family: 'Inter Variable';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap; /* show fallback immediately, swap when ready */
}
</style>Preload the fonts with crossorigin since fonts are CORS-fetched. Omitting it results in double-downloads.
font-display: swapor optional for non-critical fonts trades a brief style swap for text that renders immediately instead of invisible text (FOIT).
Subset and use unicode-range so Latin users don’t download CJK glyphs; variable fonts collapse several weights into one file.
Advanced: size-adjust/metric-override on the fallback font reduces the swap’s layout shift (CLS).
7. Loading CSS Without Render Blocking
CSS is render-blocking by design, painting unstyled HTML produces a flash of unstyled content and a layout thrash. The strategy is therefore not “make CSS non-blocking” globally, but minimize and split it.
Inline critical CSS. Extract the CSS actually used by above-the-fold content (often 10–20 KB even for complex sites) into a <style> block in the HTML head. Zero requests, zero render-blocking round trips.
Tools like Penthouse, Critical, or beasties (formerly critters) automate extraction at build time.
Load the rest asynchronously. The remaining site-wide stylesheet is fetched without blocking paint:
<link rel="preload" href="/css/full.css" as="style"
onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/full.css"></noscript>The preload downloads at high priority without block rendering.
-The onload swap applies it when ready.
-The noscript fallback covers no-JS visitors.
-An older media="print" + onload swap trick achieves the same and avoids preload’s priority competition.
Split CSS by route/component exactly like JavaScript. Bundlers generate per-route CSS chunks; a blog page doesn’t need the checkout’s styles.
Don’t let CSS block scripts unnecessarily: a <script src> placed after a <link rel="stylesheet"> waits on the stylesheet. Order CSS after scripts, or use defer/modules, to avoid chaining their critical paths.
8. Architecture-Level Loading Strategies
Beyond individual tags and attributes, the way you architect the application determines the shape of its loading problem. The 2020s produced several distinct patterns; knowing their names and trade-offs will make framework docs legible.
Code Splitting Granularity
Splitting is not just routes vs. everything. In practice teams layer:
- route-level splits the default in every meta-framework
- component-level splits heavy widgets — editors, charts, maps
- library-level splits moment/lodash/date-fns pulled per usage site.
The anti-patterns are equally important. Splitting so finely that a route needs 50 requests (waterfall return), or prefetching everything “to be safe” is bandwidth theft from the critical path, especially on mobile data.
Streaming SSR
Server-side rendering historically had the server render the complete HTML before sending it which makes the whole page wait. Streaming SSR sends HTML in chunks as it becomes ready.
Combined with selective hydration the framework hydrates interactive islands as their code and data arrive, prioritizing what the user is interacting with. React 18+/Vue/SvelteKit all support some form of “show real content early, hydrate progressively.” The user sees meaningful content far earlier than monolithic SSR or SPA approaches allow, and LCP typically improves accordingly.
Islands Architecture and Server Components
The islands architecture inverts the SPA model. The page is server-rendered static HTML with the interactive components such as the carousel, the search box, the comment form, shipping JavaScript as “islands” that hydrate independently. Most of the page costs zero JS. Static islands are even served from CDN edge caches.
React’s Server Components (RSC) generalize the idea inside the React ecosystem: components that run only on the server. RSC’s never ship to the browser with no bundle cost at all. They are composing small client components for interactivity, streamed over a serializable protocol.
Third-Party Scripts: Facades and Workers
Third-party embeds such as chat widgets, video players, maps, and social buttons are the heaviest and least controlled part of a page. They include two mitigation patterns:
- Facades (interaction-ready placeholders): render a lightweight fake placeholder. A thumbnail with a play button for video, a styled button for the chat widget then load the real third-party only on interaction. For a video that 5% of users play, this removes 1 MB of JS from 95% of loads. Libraries like lite-youtube-embed package the pattern;
next/script‘slazyOnloadand Nuxt Scripts automate the trigger. - Partytown / web-worker offloading: run the third-party’s JS in a web worker, keeping its main-thread cost off the interaction path.
next/scriptexposes this as the experimentalworkerstrategy. Effective for tag managers and analytics collectors whose DOM needs are minimal.
9. Framework Differences: Next.js, React, Vue, and Svelte
What are the changes when you load scripts and modules in each ecosystem starting with Next.js
Next.js
Rendering modes
The Pages Router offered per-page server-side-rendering (SSR).
The App Router (current default) adds React Server Components and streaming SSR out of the box.
The server components’ code stays on the server, page HTML streams as chunks, and client components hydrate selectively.
Turbopack, the Rust-based bundler, is now the default dev engine and production-ready, cutting both dev startup and build times dramatically.
Lazy loading Example
import dynamic from 'next/dynamic';
import { lazy, Suspense } from 'react';
// next/dynamic — Next-aware wrapper around dynamic import():
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
ssr: false, // skip server render for this component
loading: () => <ChartSkeleton />, // shown while the chunk loads
});
// Plain React.lazy works in the App Router too:
const Settings = lazy(() => import('./Settings'));Route-level splitting is automatic so you don’t need to bundle the whole app for one page. next/dynamic adds SSR opt-out and a loading UI on top of React.lazy; inside App Router they’re nearly interchangeable.
Third-party scripts: next/script
Rather than hand-writing <script> tags with async/defer, you declare a “strategy” which is the attribute name:
import Script from 'next/script';
<Script src="https://analytics.example.com/loader.js" strategy="afterInteractive" />beforeInteractive – placed in the root layout then injected, downloaded and executed before any Next.js code runs and before hydration. Reserved for scripts the site cannot function without bot detection or consent managers.
afterInteractive – (default) loads after the page starts hydrating; some code runs on the main thread but the initial paint isn’t blocked. For analytics and tag managers.
lazyOnload – loaded during browser idle time, lowest priority. For chat widgets, video players, anything non-essential.
worker (experimental) – executes inside a web worker via Partytown, keeping third-party JS off the main thread entirely.
Link prefetching – The Link tag prefetches route chunks automatically. By default, links that enter the viewport have their route’s code and prefetched data fetched in the background at low priority.
App Router fetches up to loading.tsx boundaries; Pages Router prefetches data for getStaticProps pages.
This is why SPA-style navigation in Next.js feels instant.
The chunks are usually warm before you click. prefetch={false} opts out; router.prefetch() prefetches programmatically (e.g., on dropdown open).
React (SPA / Vite)
React.lazy + Suspense: A Vite SPA gives you the platform with React’s hydration-aware wrapper.
import { lazy, Suspense, startTransition } from 'react';
const AdminPanel = lazy(() => import('./routes/AdminPanel'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<AdminPanel />
</Suspense>
);
}React.lazy takes an import() promise and produces a component.Suspense renders the fallback without unmounting the rest of the tree.
This is the difference from if (!loaded) return <Spinner/> patterns.
Error boundaries pair with it to catch failed chunk loads.
React 19’s concurrent features interact with loading subtly: startTransition and useDeferredValue mark updates as non-urgent, letting React keep the UI responsive while a lazy chunk’s content is being prepared. Suspense boundaries define the splitting of the wait itself.
React Server Components are no longer Next-only: React Router v7 and TanStack Start both implement RSC-style streaming with their own conventions and Vite provides the underlying RSC plugin layer. The lazy + Suspense client pattern remains universal across all of them.
Vue 3 and Nuxt
Vue’s native lazy-loading primitive is defineAsyncComponent example:
import { defineAsyncComponent } from 'vue';
const HeavyChart = defineAsyncComponent({
loader: () => import('./components/HeavyChart.vue'),
loadingComponent: ChartSkeleton,
delay: 150, // show fallback only if loading exceeds 150ms
timeout: 10000, // give up after 10s (triggers errorComponent)
errorComponent: ChartError,
});It compiles to the same dynamic import() as React.lazy but bakes in the production concerns as first-class options.
They are the fallback UI, delay before showing the fallback (spinners), timeout, and error state.
Vue’s <Suspense> component orchestrates multiple async dependencies with pending/fallback slots which are used mostly at page/route granularity.
Nuxt (the Vue meta-framework) automates what Vite/React-SPA developers wire by hand:
Route-level code splitting is automatic so pages/ become their own chunk.
<NuxtLink> smart prefetching links are prefetched when they enter the viewport (IntersectionObserver). Configurable per link (:prefetch="false") and globally.
Vite’s modulepreload machinery (inherited by Nuxt): for every dynamically imported entry, Vite emits modulepreload links for the entire static dependency chain of each lazy chunk, plus a small polyfill for older browsers. This is why Vite apps’ waterfalls look flat. The dependency graph is preloaded recursively.
Nuxt Scripts / useScript: opt-in third-party script management with load triggers (onIdle, onVisible, interaction-based).
Raw import() and Vue’s <script setup> compose cleanly too: awaiting a dynamic import inside onMounted, or defineAsyncComponent at any component boundary.
Svelte 5 and SvelteKit
Svelte is a compiler, not runtime. Components compile to minimal imperative DOM code with no virtual DOM and no framework runtime shipped to the browser. A Svelte 5 application’s base JS is dramatically smaller than an equivalent React or Vue app. However, a real-world counterpoint is that a compiled Svelte component might be a few kilobytes where React’s runtime alone is ~45kb before any app code.
SvelteKit handles loading like Nuxt/Next with its own vocabulary:
- Automatic route splitting each route is its own chunk, code-split and lazily loaded on navigation.
data-sveltekit-preload-dataa magic attribute controlling when a link’s data and code for the target route load.
Values:'tap','hover', and'viewport'. Set it on<body>to configure globally:
<body data-sveltekit-preload-data="hover">preloadfunctions (a page hook) let a page programmatically triggerpreloadData()/preloadCode()for routes it knows come next.- In-component lazy loading: Svelte’s
{#await}block plus dynamicimport()gives dependency-free async component/data rendering;svelte:componentwith an imported constructor is the older pattern.
<script>
let Chart; // filled after the chunk loads
async function loadChart() {
({ default: Chart } = await import('$lib/HeavyChart.svelte'));
}
</script>
<button on:click={loadChart}>Show chart</button>
{#if Chart}<Chart data={points} />{/if}Side-by-Side Summary
| Concern | Next.js | React (SPA/Vite) | Vue / Nuxt | Svelte / SvelteKit |
|---|---|---|---|---|
| Route splitting | Automatic (both routers) | Manual (lazy routes) | Nuxt: automatic; Vue: router lazy imports | Automatic |
| Component lazy API | next/dynamic, React.lazy | React.lazy + Suspense | defineAsyncComponent + <Suspense> | dynamic import() + {#await} |
| Third-party scripts | next/script strategies incl. worker | DIY (async/defer, facades) | Nuxt Scripts useScript triggers | DIY (facades) |
| Link prefetch | <Link> viewport prefetch (auto) | DIY (hover/IntersectionObserver) | <NuxtLink> viewport prefetch | data-sveltekit-preload-data (hover default) |
| Server-side JS elimination | RSC (App Router) | RSC in RR7/TanStack Start | Nuxt server components (islands, partial) | SvelteKit server-only load, zero-JS pages |
| Baseline client JS | Medium (React runtime, minus RSC share) | React runtime | Vue runtime (smaller) | Minimal (compiler output) |
| Module preloading | Emitted by bundler (Turbopack/webpack) | Vite recursive modulepreload + polyfill | Vite (same) | Vite (same) |
SUMMARY
-The browser mechanisms are identical; the frameworks differ in defaults and how much JavaScript exists to load.
-Next.js offers the most prescriptive script-strategy API and deepest RSC integration.
-Nuxt and SvelteKit automate the same fundamentals with lighter runtimes.
-Plain React/Vue SPAs give you full control and full responsibility.
-SvelteKit tends to win on smallest payload; Next.js/Nuxt on specialized script control.
10. Measuring and Debugging Loading
The workflow is always measure → change → re-measure.
| Chrome DevTools Name | Purpose |
|---|---|
| Network panel | enable the Priority column; throttle to “Fast 3G/Slow 4G” and disable cache to see realistic waterfalls; look for late-discovered critical resources, gaps, and double-fetches. The initiator column tells you what triggered each request. |
| Performance panel | record a load; find long tasks (parse/compile/execute) on the main thread; verify when hydration happens; check whether LCP fired before or after your JS ran. |
| Coverage tab | records which bytes of JS/CSS actually executed on load — the definitive tool for finding dead code worth splitting away. |
| Lighthouse | lab-synthesized Core Web Vitals plus specific loading audits (render-blocking resources, unused JS, LCP breakdown by phase: TTFB → load delay → load time → render delay |
| Field data | Lighthouse is lab data on a simulated device; |
| CrUX (Chrome UX Report) | real-user data from the field. |
| PageSpeed Insights | shows both side by side; optimize against field data, diagnose with lab data. For custom monitoring, the web-vitals JavaScript library reports LCP/INP/CLS (with attribution: which element, which phase) to your analytics endpoint, so regressions surface in production dashboards rather than user complaints. |
| Bundle analysis | webpack-bundle-analyzer, rollup-plugin-visualizer (Vite), and Next.js’s --analyze mode render your chunks as a treemap. |
Hire Runastartup to build, optimize or scale your web app!
![]()