
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.
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, so you can use the highest compression profile, which is discouraged when compressing on-the-fly. For example, for brotli compression:
brotli --best my-file.js
That 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";
}
We’ll discuss two techniques. Option 1 is client-initiated, while Option 2 is similar to a server side include (SSI).
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 without server side rendering (SSR).
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 too because these HTML files are like 1.5 kB.
In this screenshot, we do not use an AOT fetch technique, so you
can see that GET /api/colors starts after the SPA is ready.

Here’s what the AOT chain looks like. Note that index.js 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';
index.htmlAdd 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-x12a3 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-y12z3 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 demo # in another tab
The vite.config.js in 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:
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="other-script-x19n3.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 */)
}
YouTube uses this technique. It’s similar to SSR, but it avoids the UI rendering overhead on the server side. It can be implemented in either a blocking or a streaming manner.
This approach is a bit simpler than 2-B. It injects the initial data into a global variable in the HTML document.
<script nonce="some-nonce-2pW">
var ytInitialData = {…}
</script>
In this case, we stream a second chunk containing the initial API data, commonly as JSON, though it is not limited to that format. The first chunk is a normal HTML document, and the second chunk contains the data in a script tag.
On the client (option2/spa.js), we subscribe to an event that is triggered when the data is loaded. On the server (option2/server.js), once the data is ready, we inject two script tags: one containing the JSON data and another that emits the event the client is already listening for.
See the option2/ directory.
You can run the demo with:
cd option2
./server.js

MIT © 2025 Eric Fortis
26 commits
Hacker News (1)
JavaScript
89.0%
HTML
5.0%
TypeScript
5.0%
CSS
1.0%

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.
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, so you can use the highest compression profile, which is discouraged when compressing on-the-fly. For example, for brotli compression:
brotli --best my-file.js
That 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";
}
We’ll discuss two techniques. Option 1 is client-initiated, while Option 2 is similar to a server side include (SSI).
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 without server side rendering (SSR).
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 too because these HTML files are like 1.5 kB.
In this screenshot, we do not use an AOT fetch technique, so you
can see that GET /api/colors starts after the SPA is ready.

Here’s what the AOT chain looks like. Note that index.js 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';
index.htmlAdd 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-x12a3 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-y12z3 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 demo # in another tab
The vite.config.js in 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:
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="other-script-x19n3.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 */)
}
YouTube uses this technique. It’s similar to SSR, but it avoids the UI rendering overhead on the server side. It can be implemented in either a blocking or a streaming manner.
This approach is a bit simpler than 2-B. It injects the initial data into a global variable in the HTML document.
<script nonce="some-nonce-2pW">
var ytInitialData = {…}
</script>
In this case, we stream a second chunk containing the initial API data, commonly as JSON, though it is not limited to that format. The first chunk is a normal HTML document, and the second chunk contains the data in a script tag.
On the client (option2/spa.js), we subscribe to an event that is triggered when the data is loaded. On the server (option2/server.js), once the data is ready, we inject two script tags: one containing the JSON data and another that emits the event the client is already listening for.
See the option2/ directory.
You can run the demo with:
cd option2
./server.js

MIT © 2025 Eric Fortis
Hacker News (1)
26 commits
JavaScript
89.0%
HTML
5.0%
TypeScript
5.0%
CSS
1.0%