ericfortis/aot-fetch-demo

Ahead of Time Fetch for speeding up SPAs

32

stars

26

commits

JavaScript

primary language

Jul 26, 2026

updated

README

Ahead of Time Fetch Demo for SPAs

TL;DR - Preload Critical API requests

Overview

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…

Long-term caching

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

Pre-compression

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).

  • 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 UI rendering overhead. This is similar to what YouTube does.

Background

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).

Option 1 - Preloading

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.

Without Ahead-of-Time (AOT) fetching

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.



With AOT

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';

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-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>

Demo

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

Setup (Vite)

The vite.config.js in this repo has an htmlPlugin function that injects index-aot-fetch.js into index.html.

Setup (Webpack)

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()
}

Option 1-D: Hold a reference to a promise

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 */)
}


Option 2: Data-Only Server-Side Includes (SSI)

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.

Option 2-A: Blocking

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>

Option 2-B: Streaming

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


License

MIT © 2025 Eric Fortis

Contributors

ericfortis

26 commits

ericfortis/aot-fetch-demo

Ahead of Time Fetch for speeding up SPAs

32

stars

26

commits

JavaScript

primary language

Jul 26, 2026

updated

README

Ahead of Time Fetch Demo for SPAs

TL;DR - Preload Critical API requests

Overview

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…

Long-term caching

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

Pre-compression

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).

  • 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 UI rendering overhead. This is similar to what YouTube does.

Background

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).

Option 1 - Preloading

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.

Without Ahead-of-Time (AOT) fetching

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.



With AOT

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';

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-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>

Demo

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

Setup (Vite)

The vite.config.js in this repo has an htmlPlugin function that injects index-aot-fetch.js into index.html.

Setup (Webpack)

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()
}

Option 1-D: Hold a reference to a promise

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 */)
}


Option 2: Data-Only Server-Side Includes (SSI)

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.

Option 2-A: Blocking

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>

Option 2-B: Streaming

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


License

MIT © 2025 Eric Fortis

See what people are saying

Contributors

ericfortis

26 commits

Languages

JavaScript

89.0%

HTML

5.0%

TypeScript

5.0%

CSS

1.0%