This article is published in English.
Resource Discovery, Not Transfer: Preloading React Apps over HTTP/3
Learn why HTTP/3 makes late resource discovery the real bottleneck in React apps, and how preloadModule, preinit, Early Hints and chunking fix it.
Upgrading a server to HTTP/3 makes bytes move faster, yet many React applications barely feel quicker afterwards. The usual reason is that the slow part of the pipeline was never the transfer: it was the moment the browser learned a resource existed at all. This guide separates those two problems, shows where QUIC genuinely helps, and walks through the tools that move discovery earlier: the React 19 resource APIs, intent-driven module preloading, 103 Early Hints, streaming SSR and a chunking strategy suited to multiplexed transport.
A waterfall that looks fine but still feels slow
Picture a team profiling an analytics dashboard over a 4G connection. Every critical chunk is already preloaded, the network waterfall looks tidy, and yet Time to Interactive stays stubbornly behind target. Each chunk finishes downloading quickly once it starts, so HTTP/3 is clearly doing its part.
Look closer and the pattern changes. The downloads are fast, but the requests begin late. React has to download, boot, run and render until it reaches a lazy boundary, and only at that point does the browser find out that Dashboard.js is needed. Hundreds of milliseconds have passed before the first byte of that chunk is even asked for. The delay lives upstream of the network, in a step most performance checklists never name separately: resource discovery, as opposed to resource delivery.
Delivery and discovery are two different problems
It helps to split "loading a resource" into two questions:
- Transfer speed: once a request is made, how fast do the bytes travel from server to browser?
- Discovery timing: at what point does the browser realise it needs those bytes in the first place?
A web font referenced from a stylesheet makes the difference obvious. The chain looks like this:
HTML → CSS → @font-face rule → font request
However fast the connection is, the font request cannot be issued until the HTML has arrived, the CSS has been fetched and parsed, and the @font-face rule has been found and matched against rendered text. That is a discovery delay. A <link rel="preload"> tag does not accelerate the font download by a single millisecond; it simply lets the browser issue the request sooner. Nearly everything in the rest of this guide is a variation on that idea.
Which tool answers which question
Each technology discussed below targets a different stage of the loading pipeline:
preconnect: which origins should the browser start talking to before any request exists?preload: which specific file will the browser find too late on its own?preloadModuleandmodulepreload: which ES module chunks should be fetched and compiled ahead of execution?preinitandpreinitModule: which resources must be not just fetched but applied or executed early?prefetch: what will probably be needed on the next navigation, at low priority?- 103 Early Hints: what does the server already know before the HTML is ready?
- Streaming SSR: how can the server reveal content, and the resources behind it, progressively?
- HTTP/3 and QUIC: once a request exists, how efficiently are its bytes carried?
Only the final item concerns transport. Everything else is about discovery, timing or scheduling, and that proportion is a fair picture of where the remaining wins usually are.
Where HTTP/2 multiplexing fell short
Under HTTP/1.1, browsers achieved parallelism by opening several TCP connections per host, typically capped at about six, with each connection carrying one resource at a time. HTTP/2 replaced that with a single connection that interleaves many streams:
HTTP/1.1 HTTP/2
──────────────── ────────────────────
TCP conn 1 → JS One connection
TCP conn 2 → CSS ├── Stream A: JS
TCP conn 3 → Font ├── Stream B: CSS
TCP conn 4 → Image ├── Stream C: Font
└── Stream D: Image
That was a real step forward, but HTTP/2 still sat on TCP, and TCP promises strictly ordered delivery of a single byte stream. It has no idea that some bytes belong to the JavaScript stream and others to the font stream. If one packet goes missing, TCP holds back everything after it until the retransmission arrives, even when the lost packet was part of Stream C and the other three streams had nothing to do with it.
This is TCP head-of-line (HOL) blocking. On clean networks it is rarely noticeable; on lossy mobile links it is the main reason HTTP/2's multiplexing never fully lived up to its promise.
What QUIC changes underneath HTTP/3
HTTP/3 swaps TCP for QUIC, which runs on UDP and handles encryption itself:
HTTP/2 HTTP/3
──────── ────────
HTTP/2 HTTP/3
↓ ↓
TCP QUIC
↓ ↓
TLS UDP
↓ ↓
IP IP
Independent streams remove transport-level HOL blocking
Because QUIC manages streams inside the transport protocol, each stream is recovered independently. A lost packet only stalls the stream it belonged to:
Stream A ──────────────────── ✓
Stream B ──────────────────── ✓
Stream C ──────── X ─ retry
Stream D ──────────────────── ✓
Streams A, B and D keep flowing while C waits for its retransmission. The measured effect is largest precisely where TCP suffered most. A Catchpoint study published in July 2025, run across six countries, reported that, on heavily lossy links, median Time to First Byte fell by 41.8%. Internal testing at Wix reported connection setup 33% faster and a 20% improvement in p75 LCP. Over a steady broadband link the advantage over HTTP/2 shrinks to roughly 5%. That asymmetry is itself informative: the gains cluster where HOL blocking used to bite. Treat these figures as snapshots from those studies rather than guarantees for your traffic, and measure your own users.
Fewer round trips before the first byte
With HTTP/2 over TCP, a new connection pays for the TCP handshake and then a separate TLS negotiation, which amounts to two round trips before any application data flows. QUIC merges the TLS 1.3 exchange into its connection setup and completes both in a single round trip. For returning visitors, 0-RTT resumption allows encrypted request data to ride along with the opening packet. On a 150 ms intercontinental link, that saves somewhere between 150 and 300 ms on every cold connection. Keep in mind that 0-RTT data can be replayed, so servers generally accept it only for idempotent requests such as fetching static assets.
Connections that survive a network switch
TCP identifies a connection by the combination of source and destination IP addresses and ports. When a phone hops from Wi-Fi to cellular, its address changes and the TCP connection dies. QUIC instead uses an opaque Connection ID, so the session can migrate to the new path. A route chunk that is being prefetched does not have to start over because a commuter's train pulled out of the station.
Does HTTP/3 make preloading redundant?
It does not, and understanding why is the core of the whole topic. HTTP/3 optimises how resources are carried; preloading optimises how early they are requested. The two act at different points in the chain:
Browser
│
│ ← "I don't know I need this yet"
↓
Resource discovery ← preload operates here
│
↓
Request
│
↓
QUIC transport ← HTTP/3 operates here
│
↓
Server
A transport protocol cannot fetch something nobody has asked for yet. In fact, faster transport makes late discovery more conspicuous. Suppose a chunk's transfer time falls from 300 ms to 80 ms. A 400 ms discovery delay that used to be partly hidden inside the total now makes up most of it. The bottleneck has moved rather than disappeared.
Why React hides dependencies from the browser
Plain HTML resources such as <img> sources and <link> stylesheets are found early, because the browser's parser (and its speculative preload scanner) sees them while reading the document. Client-rendered React introduces a much longer chain before some dependencies become visible:
HTML → main.js → React executes → render → lazy() → discover Dashboard.js → download → render
With React.lazy(), the import sits behind JavaScript execution. The browser cannot learn that Dashboard.js exists until the main bundle has been downloaded, parsed, compiled and run, and React has rendered far enough to reach the lazy component. On a first visit from a slow phone, that can mean seconds before the chunk request begins.
const Dashboard = lazy(() => import("./Dashboard"));
// The browser has no idea Dashboard.js exists
// until this renders. And it only renders after
// React has fully bootstrapped.
Suspense improves the wait, not the discovery
A frequent misconception is that wrapping the component in Suspense solves the problem. It does not change when the chunk is requested:
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
What Suspense provides is coordination: while the lazy component is unresolved, React shows the fallback instead of blocking the whole tree. That is valuable for perceived quality, but it is not a mechanism for predicting resources. The request still starts at the same late moment. HTTP/3 will carry the chunk efficiently after that, yet it has no influence over how long it took to get there.
React 19's resource APIs and what each one really does
React 19 ships a family of functions in react-dom that let components feed resource hints into the browser's scheduler at the exact point during rendering when the need becomes known. They are more than thin wrappers over HTML tags: React deduplicates them, and during server rendering it can emit them into the document head so the browser sees them early.
preconnect: warm up an origin
Use preconnect when a cross-origin request is certain to follow soon. It starts DNS resolution, the connection and the TLS handshake ahead of time.
import { preconnect } from "react-dom";
// Call this when you know a cross-origin
// request is coming - not just "might be coming."
preconnect("https://cdn.example.com");
Reserve it for origins you will definitely contact. Each warmed connection costs work on both client and server, and an unused one is simply thrown away.
preload: fetch a specific file early
preload tells the browser to begin downloading a known resource without executing or applying it. Fonts are the classic case, because they are otherwise hidden behind CSS parsing:
import { preload } from "react-dom";
// Font hidden behind CSS - the browser won't
// find this until it processes @font-face.
// Preload surfaces it earlier.
preload("/fonts/inter.woff2", {
as: "font",
crossOrigin: "anonymous",
});
Note the crossOrigin: "anonymous" option. Fonts are always requested in CORS mode, so a font preload without it produces a request that does not match the real one, and the browser ends up downloading the file twice.
preloadModule: fetch and compile an ES module
preloadModule expresses the same intent for ES modules, and goes a step further: the module is downloaded, parsed and compiled, then kept in its module map, so it can be evaluated the moment an import() asks for it.
import { preloadModule } from "react-dom";
// Use this for lazy route chunks you know
// are likely to be needed soon.
preloadModule("/assets/Dashboard-abc123.js");
This is the natural fit for lazy route chunks that are likely to be needed shortly.
preinit and preinitModule: fetch and put to use
preinit and preinitModule are the stronger variants. They fetch the resource and also make it take effect: a stylesheet is inserted and applied, and a script is executed once it arrives.
import { preinit } from "react-dom";
// You don't just want this downloaded -
// you want it applied before render.
preinit("/styles/app.css", { as: "style" });
The gap between the two families matters most for CSS. A preloaded stylesheet is downloaded but not applied. If that stylesheet is required before first paint, you have moved the download earlier but rendering still waits until something actually inserts it. preinit covers both steps. For scripts, the reverse caution applies: only preinit code that is safe to run immediately.
Triggering preloadModule from user intent
Calling preloadModule for every route at startup wastes bandwidth. The best moment is when the user's intent becomes visible, which usually means a pointer entering, or keyboard focus landing on, a navigation link just before the click.
The component below wires that up. It renders a normal anchor so the link still works without JavaScript, calls preloadModule on both onMouseEnter and onFocus so keyboard users benefit too, and hands the actual navigation to React Router's navigate:
import { preloadModule } from "react-dom";
import { useNavigate } from "react-router-dom";
function NavLink({ to, chunkPath, children }) {
const navigate = useNavigate();
return (
<a
href={to}
onMouseEnter={() => preloadModule(chunkPath)}
onFocus={() => preloadModule(chunkPath)}
onClick={(e) => {
e.preventDefault();
navigate(to);
}}
>
{children}
</a>
);
}
// Usage
<NavLink to="/dashboard" chunkPath="/assets/Dashboard-abc123.js">
Dashboard
</NavLink>
The gap between hover and click is commonly somewhere around 100 to 400 ms. Over HTTP/3, a medium-sized chunk can often finish inside that window: at 10 Mbps, 150 KB takes about 120 ms. By the time the click lands, the module is already compiled in the module map, the lazy boundary resolves right away, and the Suspense fallback never appears.
What the onMouseEnter handler achieves is something no transport protocol can: it turns a signal of intent into knowledge about a resource before the navigation is requested. HTTP/3 then handles the transfer efficiently. Each layer does its own job.
Two practical notes. First, chunkPath has to be the hashed filename your bundler actually produced, so in a real project it should come from the build manifest rather than being typed by hand. Second, touch devices have no hover, so consider onTouchStart or viewport-based triggers if mobile navigation matters to you.
Bundler-level prefetching
For bulk, low-priority prefetching during idle time, webpack supports a magic comment inside the dynamic import:
const Dashboard = lazy(
() => import(/* webpackPrefetch: true */ "./Dashboard")
);
Be aware that webpackPrefetch results in a <link rel="prefetch"> hint, which the browser treats as idle-time, low-priority work for a probable future navigation. That is a different signal from the high-priority preload or modulepreload used for resources needed now.
Vite takes a more automatic approach and emits modulepreload links for you. With webpack you need the magic comment or a plugin. The native HTML form of the hint looks like this:
<!-- Vite generates these for lazy chunks automatically -->
<link rel="modulepreload" href="/assets/Dashboard-abc123.js">
<link rel="modulepreload" href="/assets/vendor-react-def456.js">
Strictly speaking, Vite's generated HTML contains modulepreload links for the entry chunk and its static imports. For dynamically imported chunks, Vite's runtime helper inserts preload links for their dependencies at the moment the import() runs, so the chunk and its imports load in parallel rather than in sequence. Check the build documentation of your Vite version for the exact behaviour.
Compared with a generic rel="preload", modulepreload lets the browser parse and compile the module as soon as it arrives instead of waiting until execution time. Over HTTP/3, several such hints travel on independent QUIC streams, so a lost packet in the vendor chunk does not hold up the Dashboard chunk.
From Server Push to 103 Early Hints
HTTP/2 tried to solve discovery on the server side with Server Push: the server would send resources the browser had not yet requested. The goal of moving knowledge earlier was right, but the execution failed. The server had no reliable way to tell whether the browser already had a resource cached, so it often pushed duplicates, burned bandwidth, and fought for capacity with resources the browser itself considered more urgent. Chrome eventually dropped support for Server Push.
The model that replaced it divides responsibility more sensibly: the server supplies information, and the browser decides what to fetch and when.
103 Early Hints put this into practice. While the server is still assembling the main response, it sends a provisional 103 status carrying Link headers. The browser can start fetching those resources immediately, and by the time the final 200 OK arrives with the HTML, some of them may already be complete.
Browser Server
│ │
│──── GET / ─────────────→ │
│ │ (generating HTML...)
│ ←─── 103 Early Hints ─── │
│ Link: </assets/main.js>; rel=modulepreload
│ Link: </assets/vendor.js>; rel=modulepreload
│ │
│ (fetching chunks now...) │ (still generating...)
│ │
│ ←─── 200 OK + HTML ───── │
│ (chunks already downloading or done)
At the time of writing, NGINX shipped built-in support for Early Hints with release 1.29.0 in June 2025, and Cloudflare exposes it as a toggle in its dashboard. In Node.js, the response object provides writeEarlyHints(), which you can call in a custom server or middleware before sending the real response:
// In a custom server or middleware
res.writeEarlyHints({
link: [
"</assets/main.js>; rel=modulepreload; as=script",
"</assets/vendor.js>; rel=modulepreload; as=script",
"</assets/Dashboard.js>; rel=modulepreload; as=script",
],
});
// Then proceed with normal response
res.status(200).send(html);
The snippet uses an Express-style res.status().send() for the final response. With a bare Node.js http server, you would use res.writeHead() and res.end() instead. Early Hints only pay off when there is genuine server think time, such as database queries or rendering, during which the browser would otherwise sit idle.
A measurement published by corewebvitals.io, using Chrome DevTools timings, found that putting a critical CSS file in Early Hints made the LCP element appear roughly 35% sooner than a conventional preload inside the HTML. For a React application, the equivalent win is getting the main chunk graph downloading while the server is still working, rather than after the HTML has arrived.
Combining streaming SSR, Early Hints and HTTP/3
React's streaming server rendering adds one more lever. Rather than holding the response until the full page is ready, the server sends HTML in stages:
HTML shell → Suspense fallback → more HTML → resolved content → hydration
During rendering the server knows which Suspense boundaries are about to render and which chunks they depend on. That knowledge can be passed to the browser through Early Hints before the HTML stream begins. A simplified timeline:
0ms ── Browser sends request
── Server starts rendering
1ms ── Server knows Dashboard boundary will render
── Server sends 103 Early Hints: Dashboard.js
── Browser starts fetching Dashboard.js
50ms ── Server streams HTML shell
── Browser starts parsing
120ms── Server streams Dashboard content
── Dashboard.js already downloaded
── Hydration starts immediately
Compare the same application without Early Hints, where discovery waits for the client:
0ms ── Browser sends request
50ms ── Server streams HTML shell
── Browser starts parsing
── Browser discovers <script> tags
── main.js starts downloading
180ms── React executes
── Hits Dashboard lazy boundary
── Dashboard.js request starts (now)
300ms── Dashboard.js downloads
── Hydration starts
HTTP/3 shortens every segment in both timelines. What Early Hints change is the point at which the Dashboard request starts, which is a different and often larger saving. If your framework's rendering is already covered by other primitives, the article on React 19.2's SSR primitives such as Activity and partial pre-rendering goes further into how streaming boundaries are produced.
Rethinking chunk granularity for multiplexed transport
The long-standing advice to minimise the number of HTTP requests came from HTTP/1.1's six-connection limit and from HTTP/2's multiplexing being only partly parallel over one TCP stream. With independent QUIC streams, request count stops being the dominant factor it used to be, so you can split more aggressively without paying the same per-request penalty.
A sensible default split looks like this:
- React and ReactDOM: a dedicated, stable vendor chunk. It rarely changes, so it can carry a long cache lifetime.
- Router library: its own chunk, for the same stability reason.
- Heavy third-party libraries such as charting or editors: one chunk per library, so updating one does not invalidate the others.
- Route components: one chunk per route through
React.lazy(), so navigation loads only what it needs. - Shared utilities: a single shared chunk produced by the bundler, loaded once and reused across routes.
- Admin or rarely used features: separate lazy chunks that users who never open those screens never download.
The payoff is cache precision. Editing Dashboard.tsx ought to bust the cache for that one route chunk while the vendor bundle stays cached, and that is only possible with granular splitting. Over HTTP/3, the resulting 8 to 15 chunks load on independent streams without HOL blocking between them.
Vite handles most of this without configuration. In webpack, splitChunks.cacheGroups expresses the same policy. The configuration below creates a React vendor chunk, a router chunk, and an async-only charts chunk; the priority values decide which group wins when a module matches more than one test, and chunks: "async" keeps charting code out of the initial load:
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
reactVendor: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: "vendor-react",
chunks: "all",
priority: 40,
},
routerVendor: {
test: /[\\/]node_modules[\\/](react-router|react-router-dom)[\\/]/,
name: "vendor-router",
chunks: "all",
priority: 30,
},
chartsVendor: {
test: /[\\/]node_modules[\\/](recharts|d3)[\\/]/,
name: "vendor-charts",
chunks: "async",
priority: 20,
},
},
},
},
};
There is a caveat. Very small chunks add per-file parse and compile overhead in the browser. And not every visitor gets HTTP/3: corporate firewalls that block UDP on port 443 are common, and those users fall back to HTTP/2, where part of the request-count cost returns. Measure before you split finer than the route level. Per-route and per-heavy-library is usually the right granularity; per-component usually is not. If your stack is Next.js, the piece on Turbopack's chunking controls covers the equivalent knobs there.
Why preloading everything still backfires
Multiplexing lets many streams share a connection, but it does not grant each of them unlimited bandwidth or make them equally important. Twenty modulepreload hints still share one pipe; the contention is simply spread over more streams.
The useful question is therefore not "what could be preloaded?" but "which important resource will the browser only notice too late?" As a starting point, consider:
- Critical font:
preload. - Critical stylesheet:
preload, orpreinitwhen it must be applied before paint. - Critical JavaScript module:
preloadModule. - Important cross-origin CDN or API origin:
preconnect. - Hero image visible on load:
preloadwithfetchpriority="high". - Images further down the page: no preload.
- Lazy route chunk:
preloadModule, triggered by user intent. - Analytics scripts: no preload.
- Chat widget: load lazily without preloading.
- Admin-only code: no preload.
- Likely next navigation:
prefetchat low priority. - Critical resources the server already knows about: 103 Early Hints.
Read every entry as "consider", not as a rule. There is no universal preload list, only resources that are both important and discovered late in your particular application.
The same restraint applies to fetchpriority. Browsers already prioritise resources with sophisticated heuristics, and marking everything high is equivalent to marking nothing. Override the default only when a measurement shows the browser getting it wrong.
Five questions for reviewing a loading strategy
When auditing how a React application loads its resources, work through the following questions in order:
- At what point is this resource discovered? If the honest answer is "after JavaScript runs" or "after React renders", there is probably room to reveal it sooner.
- At what moment does the user need it? Something can be important without being needed immediately. That distinction decides between
preload(now) andprefetch(idle time). - Can that knowledge be communicated earlier? Roughly in increasing order of effort:
preconnect, thenpreloadorpreloadModule, thenpreinit, then 103 Early Hints, then streaming SSR with hints the server derives from what it is rendering. - What does it compete with? Every hint has an opportunity cost. Preloading the Dashboard chunk means something else gets less attention, so know what that something is.
- Is the constraint the network or the CPU? Preloading a 2 MB bundle moves its download earlier but does nothing for parse, compile and execution time. If the main thread is the constraint, earlier discovery only changes where the user waits, not for how long.
How the layers fit together
Seen end to end, loading a modern React application involves six layers, each with its own scope:
Application intent (React knows which routes and components are needed)
↓
Resource APIs (preconnect / preload / preloadModule / preinit)
↓
Server-side surfacing (103 Early Hints / streaming SSR)
↓
Browser resource scheduler (priority, cache, bandwidth estimation)
↓
HTTP/3 / QUIC transport (independent streams, 0-RTT, connection migration)
↓
Network
The older approach pushed resources at the client as hard as possible: Server Push, preloading everything, and consolidating bundles to cut request counts. The current one is to hand the browser richer signals at each layer and let it make the scheduling calls. HTTP/3 supplies a transport that can act on those decisions efficiently. Early Hints carry server knowledge to the browser before the HTML exists. React's resource APIs let the application state its intent at the moment a component resolves.
No layer substitutes for another. HTTP/3 cannot fetch what has not been discovered. Early Hints are useless when the server has no idea which routes are rendering. And preloadModule cannot rescue a monolithic 2 MB bundle that needs 800 ms to compile on a mid-range phone.
Key takeaways
The biggest effect of HTTP/3 on preloading is not that transfers got faster; it is that faster transfers made late discovery relatively more expensive. When a 400 ms transfer becomes 80 ms, the discovery delay that used to hide inside it becomes the dominant cost, and a strategy tuned for the old bottleneck is now tuning the wrong thing.
- Preload a resource because the browser would otherwise find it too late, not merely because it matters. Important resources that are discovered on time need no hint, and unimportant ones that are discovered late do not deserve one.
Suspenseimproves what the user sees while waiting; it does not make chunks load sooner.- Choose the weakest hint that works:
preconnectfor origins,preloadfor files,preloadModulefor modules,preinitwhen something must be applied or run. - Trigger route-chunk preloading from intent signals such as hover and focus, and let Early Hints and streaming SSR surface what the server already knows.
- Split by route and by heavy library, remember the HTTP/2 fallback, and measure before going finer.
HTTP/3 did not make preloading obsolete. It made it clearer what preloading is for.