This repo shows a few ways to speed up the cold start of SPAs.
Cold start meaning either the initial load, or a version update. On subsequent (warm) loads this technique is not needed because static assets can be fully cached.
About long-term caching and pre-compression…
For long-term caching, the quick win is versioning static
filenames (e.g., script-<hash>.js) and serving them with a
cache header with an immutable flag, which avoids revalidation:
Cache-Control: public,max-age=31536000,immutable
Another win is precompressing static assets. That way, you can use the highest compression profile, which is discouraged when compressing on-the-fly. For example, for brotli compression:
brotli --best my-file.jsThat command outputs my-file.js.br, so e.g., with Nginx, you can
use the brotli static module, which
will look for a file with that extra .br extension.
location /assets {
#…
brotli_static on;
add_header Cache-Control "public,max-age=31536000,immutable";
}Most Single Page Applications (SPAs) initiate all backend requests from a static JavaScript file. In those cases, that static file needs to be downloaded before initiating API requests. But that chain doesn’t have to be sequential. We can concurrently request dynamic APIs and static assets APIs without server side rendering (SSR).
In this repository we discuss two techniques. Option 1 is client-initiated, while Option 2 is similar to a server side include (SSI).
- Option 1 is about indicating which APIs we want to preload.
- Option 2 streams a chunk with only the API data, so there’s no rendering overhead.
We’ll discuss four alternatives for this option. Three for preloading APIs with
Links,
and fourth one for preloading with fetch(). Their performance difference
is negligible — they all start right after downloading the HTML
document. On the other hand, Option 2 has a potential, but slight, advantage
because it can initiate the API call before the HTML is sent. At any rate,
it’s pretty negligible because these HTML files are like 1.5 kB. Also, because
the requests reuse the TCP connection, so there’s no handshake overhead.
In this screenshot, we do not use an AOT fetch technique, so you
can see that GET /api/colors starts only after the SPA is ready.
On the other hand, here’s what AOT fetch looks like. Note
the index.html and the API request download concurrently.
Add a Link header when sending the HTML document. For example,
if you use Nginx to serve your index.html, you can:
add_header Link '</api/colors>; rel=preload; as=fetch; crossorigin=use-credentials';Add a link tag:
<head>
<link rel="preload" href="/api/colors" as="fetch" crossorigin="use-credentials">
…
</head>This is similar to 1-B, but it’s injected with an inline script. I use
this option in my project because I conditionally
prefetch APIs based on a value in the user’s localStorage.
<html>
<head>
<script type="module" src="script-123 does not block because is type module.js"></script>
<script>
preload('/api/colors')
function preload(url) {
const link = document.createElement('link')
link.as = 'fetch'
link.rel = 'preload'
link.href = url
link.crossOrigin = 'use-credentials'
document.head.appendChild(link)
}
</script>
<link rel="stylesheet" href="style-123 blocks so it goes after preloading.css" />
</head>
<body>
</body>
</html>git clone https://github.com/ericfortis/aot-fetch-demo.git
cd aot-fetch-demo
npm install
npm run backend
npm run dev # in another terminal The screenshots above are from a built SPA because the performance-tab graphs are cleaner that way. If you prefer this approach, you can run this instead:
npm run build
npm run backendThen, open http://localhost:2345
The vite.config.js of this repo has an htmlPlugin function
that injects index-aot-fetch.js into index.html.
This repo doesn’t include a Webpack setup, but you could do it like this:
Webpack setup
import HtmlWebpackPlugin from 'html-webpack-plugin'
// config
plugins: [
new HtmlWebpackPlugin({ templateContent: htmlTemplate() })
]import { readFileSync } from 'node:fs'
export const htmlTemplate = () => `<!DOCTYPE html>
<html>
<head>
<script>${readAotFetch()}</script>
<link rel="stylesheet" … />
</head>
<body>
…
</body>
</html>`
function readAotFetch() {
return readFileSync('./index-aot-fetch.js', 'utf8').trim()
}This is what I used to do before knowing about rel=preload; as=fetch, but I reckon
it could be useful if you need to include custom headers. For example:
<html>
<head>
<script type="module" src="script-123-does-not-block-because-is-module.js"></script>
<script>
window._aotFetch = {
'/api/colors': fetch('/api/colors', /* custom headers */)
}
</script>
</head>
<body>
</body>
</html>Then, await that promise in your SPA.
const getColors = () => aotFetch('/api/colors')
function aotFetch(url) {
if (window._aotFetch?.[url]) {
const promise = window._aotFetch[url]
delete window._aotFetch[url]
return promise
}
return fetch(url, /* custom headers */)
}- https://oliverjam.es/articles/preload-data
- https://martinfowler.com/articles/data-fetch-spa.html#prefetching
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preload#cors-enabled_fetches
This technique is similar to SSR, but it avoids the rendering overhead. It just streams the API data, commonly as JSON but not limited to it.
The demo streams index.html document in two parts.
The document as is, and a second chunk with the API response
in a script tag. Then, on the client (option2/spa.js), we
subscribe to an event that is triggered when the data is loaded.
See the option2/ directory, you can run the demo with:
cd option2
./server.jsMIT © 2025 Eric Fortis


