Translated from the original by the Deno team: A Gentle Introduction to Islands
Modern JavaScript web frameworks include a lot of JavaScript.
However, most websites do not need to include that much JavaScript. But some websites do require it. If you are developing a dynamic, interactive dashboard, you can use JavaScript to your heart's content. On the other hand, documentation pages, blogs, static content websites, and so on do not require any JavaScript. For example, the original article of this blog does not include any JavaScript.
However, many websites are in an intermediate state where they need some interactivity, but not too much:
These "Goldilocks" websites are precisely where the problem lies with current frameworks: you cannot statically generate these pages, but packaging the entire framework and sending it over the network to the user just for an image carousel button seems wasteful. What can we do for such websites?
Give them the islands architecture.
What are Islands?#
This is our merch site, developed using Fresh, a web framework based on the Islands architecture developed with Deno.
The main content of this page is static HTML: headers and footers, titles, links, and text. These do not require interactivity, so no JavaScript is used. However, three elements on the page require interactivity:
- "Add to Cart" button
- Image carousel
- Shopping cart button
These are the islands. Islands are isolated Preact components that will hydrate on the client side with the statically rendered HTML.
- Isolated: These components are independently written and published, unrelated to other parts of the page;
- Preact: A React alternative that is only 3kb in size, so even when Fresh is publishing islands, it still uses the minimal amount of JS;
- Hydration: How JavaScript from server-rendered content is added to the client page;
- Statically rendered HTML pages: Basic HTML without JavaScript is sent from the server to the client, and if there are no islands used on the page, only HTML will be sent.
The most critical part is hydration. This is the problem that JavaScript frameworks are struggling to solve because it is the foundation of how frameworks work, but at the same time, hydration is pure overhead.
JavaScript frameworks hydrate pages, but the Islands framework hydrates components.
The Problem with Hydration - “Hydrate level 4, please”#
Why is so much JavaScript sent when not using the Islands architecture? Because this is how modern "meta" JavaScript frameworks operate. You can use a framework to create your content and add interactivity to the page, sending them separately, and then using a mechanism called "hydration" in the browser to merge them.
Initially, these things were separate. You had a server-side framework to generate HTML (PHP, Django, to NodeJS) and a client-side plugin to provide interactivity (most commonly jQuery). Then we entered the realm of React SPAs, where everything became client-side. You published a basic HTML framework, and the entire site, including content, data, and interactivity, was generated on the client side.
Later, pages became larger, and SPAs became slower. Server-side rendering came back, but with the same parent adding interactivity instead of a separate plugin. You create the entire application with JavaScript, and during the build phase, interactivity and the initial state of the application (the state of components and any data fetched from the API server) are serialized and bundled into JavaScript and JSON.
When a page is requested, the server sends HTML along with the bundled JavaScript needed for interactivity and state. Then, the client "hydrates" the JavaScript, which means:
- Traversing the entire DOM tree from the root;
- For each DOM node, if it is interactive, adding event listeners, setting the initial state, and then re-rendering. If the node does not require interactivity, it reconciles (reconcile) using the original DOM node.
Using this method, HTML is quickly displayed, and users do not have to stare at a white screen waiting for JavaScript to load and give the page interactivity.
Hydration looks like this:
The build phase extracts all the essential parts from your application, leaving a shriveled shell. Then, you can send this shriveled shell along with the separate water to be combined by the client's Black & Decker hydrator browser. This gives you an edible pizza / usable website (thanks to this SO answer analogy).
What is the problem with this approach? Hydration treats the page as a single component. Hydration occurs top-down, traversing the entire DOM tree looking for nodes that need to be hydrated. Even if you break your application down into components during development, this information will be discarded during hydration, and everything will be bundled together for release.
Applications developed with these frameworks also send the JavaScript that comes with the framework. If we create a new Next application, remove everything and keep only an h1
tag on the homepage, we will still find JavaScript sent to the client, including a JavaScript version of the h1
render function, even though the build phase knows this page can be statically generated.
Code splitting and progressive hydration are workarounds to solve this fundamental problem. They split the originally bundled code and hydration process into separate chunks or steps. This can speed up the time it takes for the page to gain interactivity because you can start hydrating the first chunk before the rest is downloaded.
However, you are still sending all the JavaScript to the client, which may not need to use it, and it must be processed for later use.
Islands Architecture in Fresh#
If we do something similar in the Deno-based web framework Fresh, we find that the application has no JavaScript.
There is nothing on this page that requires JavaScript, so no JavaScript is sent.
Now let’s add some JavaScript in the form of islands.
So we have 3 JavaScript files:
chunk-A2AFYW5X.js
island-counter.js
main.js
To demonstrate how these JavaScript files are generated, here is the timeline of what happens after the request is received.
Rendering a Fresh application:
- Server-side:
- The Fresh edge server receives an HTTP request;
- Fresh locates islands from the manifest file;
- Creates vnodes, Preact locates "island" nodes and adds corresponding HTML comments;
- The required JavaScript files are generated and bundled, ready to be sent to the client;
- The server sends HTML and the JavaScript files needed for hydration;
- Client-side:
- The browser receives the HTML and caches all static resources, including JavaScript;
- The browser runs
main.js
, traverses all islands, traverses the DOM tree looking for HTML comments, and then hydrates them; - Islands can now interact.
Note that this timeline is for the first request of a Fresh application. For subsequent requests of already cached static resources, it only needs to retrieve them from the cache.
Let’s delve into some key steps to see how islands work.
Checking for Islands in fresh.gen.ts
Manifest#
The first step in locating all islands is to check the manifest
from fresh.gen.ts
. This is a document automatically generated by your application that lists all pages and islands in the application.
// fresh.gen.ts
import config from "./deno.json" assert { type: "json" };
import * as $0 from "./routes/index.tsx";
import * as $$0 from "./islands/Counter.tsx";
const manifest = {
routes: {
"./routes/index.tsx": $0,
},
islands: {
"./islands/Counter.tsx": $$0,
},
baseUrl: import.meta.url,
config,
};
export default manifest;
The Fresh framework processes the manifest into different pages (not shown here) and components. Any islands will be passed into an islands array.
// context.ts
// Overly simplified for sake of example.
for (const [self, module] of Object.entries(manifest.islands)) {
const url = new URL(self, baseUrl).href;
if (typeof module.default !== "function") {
throw new TypeError(
`Islands must default export a component ('${self}').`,
);
}
islands.push({ url, component: module.default });
}
Replacing Each Island with Unique HTML Comments During Server-Side Rendering#
During server-side rendering in render.ts, Preact creates a virtual DOM. Since each virtual DOM will be created, Preact's options.vnode.hook will be called.
// render.ts
options.vnode = (vnode) => {
assetHashingHook(vnode);
const originalType = vnode.type as ComponentType<unknown>;
if (typeof vnode.type === "function") {
const island = ISLANDS.find((island) => island.component === originalType);
if (island) {
if (ignoreNext) {
ignoreNext = false;
return;
}
ENCOUNTERED_ISLANDS.add(island);
vnode.type = (props) => {
ignoreNext = true;
const child = h(originalType, props);
ISLAND_PROPS.push(props);
return h(
`!--frsh-${island.id}:${ISLAND_PROPS.length - 1}--`,
null,
child,
);
};
}
}
if (originalHook) originalHook(vnode);
};
Dynamically Generating the Hydration Script#
The next step is to generate the hydration script based on the detected islands, specifically for all islands added to the ENCOUNTERED_ISLANDS
set.
In render.ts
, if ENCOUNTERED_ISLANDS
is not an empty set, we will add statements for the hydration script to be sent to the client, importing the revive
function from main.js
.
if (ENCOUNTERED_ISLANDS.size > 0) {
// ...
script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;
Note that if ENCOUNTERED_ISLANDS
is an empty set, the entire islands processing will be skipped, and no JavaScript will be sent to the client.
Then, the render
function will add the JavaScript for each island (/island-${island.id}.js
) to an array while also adding the corresponding import
statements to the script
.
//render.ts, continued
let islandRegistry = "";
for (const island of ENCOUNTERED_ISLANDS) {
const url = bundleAssetUrl(`/island-${island.id}.js`);
script += `import ${island.name} from "${url}";`;
islandRegistry += `${island.id}:${island.name},`;
}
script += `revive({${islandRegistry}}, STATE[0]);`;
}
At the end of the render
function, the script
string composed of all import
statements and the revive()
function will be added to the HTML. Additionally, the URL paths of each island's JavaScript will be rendered as an HTML string in the import
array.
Here’s how the script
string looks when loaded into the browser.
<script type="module">
const STATE_COMPONENT = document.getElementById("__FRSH_STATE");
const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");
import { revive } from "/_frsh/js/1fx0e17w05dg/main.js";
import Counter from "/_frsh/js/1fx0e17w05dg/island-counter.js";
revive({counter:Counter,}, STATE[0]);
</script>
For clarity, line breaks have been added between statements.
When this string is loaded by the browser, it will run the revive
method from main.js
to hydrate the Counter
island.
The Browser Runs revive
#
The revive
function is defined in main.js
(the minified version of main.ts
). It traverses the virtual DOM, searching for HTML comments that match the regular expression added by Fresh in previous steps.
// main.js
function revive(islands, props) {
function walk(node) {
let tag = node.nodeType === 8 &&
(node.data.match(/^\s*frsh-(.*)\s*$/) || [])[1],
endNode = null;
if (tag) {
let startNode = node,
children = [],
parent = node.parentNode;
for (; (node = node.nextSibling) && node.nodeType !== 8;) {
children.push(node);
}
startNode.parentNode.removeChild(startNode);
let [id, n] = tag.split(":");
re(
ee(islands[id], props[Number(n)]),
createRootFragment(parent, children),
), endNode = node;
}
let sib = node.nextSibling,
fc = node.firstChild;
endNode && endNode.parentNode?.removeChild(endNode),
sib && walk(sib),
fc && walk(fc);
}
walk(document.body);
}
var originalHook = d.vnode;
d.vnode = (vnode) => {
assetHashingHook(vnode), originalHook && originalHook(vnode);
};
export { revive };
If we look at index.html
, we will find the following comment that can match the regular expression in the revive
function:
<!--frsh-counter:0-->
When the revive
function discovers this comment, it will call createRootFragment
using Preact's render
/ h
function to render this component.
Now the client has an interactive island that can be used immediately!
Islands in Other Frameworks#
Fresh is not the only framework using the islands architecture. Astro is also based on the islands architecture but uses different configurations, allowing you to specify how each component loads JavaScript. For example, this component does not need to load JavaScript.
<MyReactComponent />
However, you can add a client directive, and now it will load JavaScript.
<MyReactComponent client:load />
Other frameworks like Marko use partial hydration. The distinction between it and islands is subtle.
In Islands, developers explicitly know which components will be hydrated and which will not. For example, in Fresh, only components in the islands directory with CamelCase or kebab-case naming will send JavaScript.
In partial hydration, components are written just like normal, and the framework will determine during the build process which JavaScript will be sent.
Another answer to this problem is React Server Components, which supports the new /app directory structure of NextJS. This helps to more clearly define what works on the server and what works on the client, although whether it reduces the amount of JavaScript sent is still up for debate.
Aside from the islands architecture, the most exciting development is Qwik's resumability feature. They completely remove the hydration step, replacing it with serializing JavaScript into the bundled HTML. Once the HTML is sent to the client, the entire application becomes usable, including all interactivity.
Summary of Islands Architecture#
Combining the islands architecture with the resumability feature may allow for sending less JavaScript and removing the hydration step.
However, the islands architecture brings more than just a smaller bundle size. One huge benefit of the islands architecture is that it provides you with a mental model during the development process. With islands, you must choose whether JavaScript is sent. You never accidentally send unnecessary JavaScript to the client. When developers build an application, the inclusion or exclusion of each interactivity should be the result of the developer's choice.
Therefore, sending less JavaScript is not the responsibility of the architecture or framework, but rather your responsibility as a developer.