Home / Articles / Resumable Islands on Bun: Design Lessons from the Stoneware Framework

This article is published in English.

Resumable Islands on Bun: Design Lessons from the Stoneware Framework

How a server-first framework treats HTML as the default, scopes JavaScript to resumable islands, and handles static export, errors, CSP and deployment.

3252 words

Most pages on the web are content with a few interactive controls sprinkled on top, yet the dominant development model ships the whole page to the browser as a JavaScript application. Stoneware, a young open-source TypeScript framework built on Bun, starts from the opposite assumption: HTML is what the browser gets by default, and a component has to explicitly opt in before any JavaScript is sent for it. Walking through its design is a useful way to understand islands, resumability, static export and the less glamorous parts of framework engineering, such as error messages, security ordering and deployment packaging. By the end you should be able to judge when a server-first, islands-based architecture fits your project and which pitfalls to watch for if you build or adopt one.

Why a content page should not become an application

Picture a typical product page. It has a title, a photo, a description, a specification list, a price, related products, reviews and site navigation. Out of all of that, perhaps two things actually respond to the user: the mobile menu and the "Add to cart" button. Everything else is static content that the server already knows how to produce.

A client-rendered or fully hydrated approach still asks the browser to download, parse and execute code that represents the entire page, just so those two controls can work. The server-first question is simple: what if the browser only received code for the parts that need it?

The mental model: HTML plus islands

The architecture splits a page into two kinds of territory. Most of it is server-rendered HTML. The interactive pieces become islands, small self-contained regions that carry their own JavaScript. Both end up in the same document in the browser.

Web page
                            │
                ┌───────────┴───────────┐
                │                       │
             HTML                    Islands
                │                       │
        Server rendered          JavaScript
                │                       │
                └───────────┬───────────┘
                            │
                         Browser

The key consequence is that interactivity no longer forces you to ship the whole application. Making one widget interactive costs you that widget's code, not the page's.

Building on Bun instead of assembling a toolchain

Choosing Bun is not a claim that Node.js is obsolete. Node has a huge ecosystem and powers a vast share of production JavaScript, and it remains a perfectly good runtime. The interesting angle is what changes when a framework is designed around a runtime that already bundles the tooling most projects need.

Bun ships a JavaScript runtime together with a package manager, a bundler and a test runner. For framework authors, that removes a lot of glue. Instead of layering a framework on top of Node, then a package manager, then a separate bundler, then a separate test runner, the design can treat all of those as one coherent foundation.

Bun
                     │
        ┌────────────┼────────────┐
        │            │            │
      Runtime     Tooling       Testing
        │            │            │
        └────────────┼────────────┘
                     ↓
                 Stoneware

It is worth keeping the roles straight: Bun is the platform, and Stoneware is the framework that sits on it. If you want a broader comparison of the runtimes themselves, see Node.js, Deno and Bun compared.

HTML first, taken seriously

Server-side rendering has been around for a long time, so "render HTML on the server" sounds unremarkable. The stronger principle behind Stoneware is that the server should produce genuinely useful HTML before the browser has to understand anything about the application.

Take a component that displays a product with a title and a price.

<ProductCard
  title="MacBook Pro"
  price={1999}
/>

Nothing about this component requires the browser to hold a JavaScript model of it. The server can turn it into plain markup:

<div class="product-card">
  <h2>MacBook Pro</h2>
  <span>$1999</span>
</div>

That output is already complete. People can read it, search engine crawlers can index it, and the browser can paint it immediately. No script is involved in delivering the content itself.

Making JavaScript an explicit decision

Now add a cart button. Unlike the product card, it has to react to clicks, update state and probably talk to an API.

<AddToCart product={product} />

That component is where client-side behavior is justified, so it becomes an island. The rest of the page stays as HTML, and the resulting page looks like this:

Product page
│
├── Product title          HTML
├── Product description    HTML
├── Product image          HTML
├── Product specifications HTML
│
└── Add to cart            JavaScript island

The page is not "JavaScript-free". It is selectively scripted, and the selection is made per component rather than per page. That distinction matters because it keeps the default cheap and makes every piece of shipped code a visible, deliberate choice.

Where the island boundary sits

An island marks the line between content rendered on the server and behavior that runs on the client. A documentation site shows the pattern clearly. The article body, headings, code samples, images, links, navigation and footer are all content. A handful of features are genuinely interactive: search, a theme switcher, copy-to-clipboard buttons on code blocks and perhaps an expandable navigation tree.

Documentation page
│
├── Article ─────────────── SSR
├── Code blocks ─────────── SSR
├── Images ──────────────── SSR
├── Navigation ──────────── SSR
│
├── Search ──────────────── Island
├── Theme switcher ──────── Island
└── Copy button ─────────── Island

This kind of page, mostly content with a few interactive regions, is exactly what the framework is designed for.

Hydration versus resumability

A fair objection is that all of this is just SSR. It partly is: the server renders HTML. The difference lies in what happens once that HTML arrives.

With classic hydration, the sequence is roughly:

  • the server sends HTML;
  • the browser downloads the application's JavaScript;
  • the framework executes and rebuilds the component tree in memory;
  • event handlers are attached to the existing DOM.

The browser ends up reconstructing an application whose output it has already received. That work is pure overhead from the user's point of view: the pixels were already on screen.

Stoneware is designed around resumable islands instead. The server sends HTML together with whatever state the interactive islands need, and the browser picks up those islands where the server left off rather than re-executing the page to rediscover that state. The aim is to stop small interactive regions from forcing a full reconstruction of the page.

A different optimization target

Resumability reframes the performance question. Instead of asking how to make hydration of everything faster, you ask how much browser work you can avoid entirely. The payload shifts from "HTML plus all the application's JavaScript plus a hydration pass" to "HTML plus only the code needed for interaction, resuming from server-rendered state". For content-heavy sites, that is often a much bigger win than any hydration optimization.

If you work in React, the same pressure is behind server components; the architecture behind zero-bundle rendering covers that approach and makes a useful comparison.

Server-first is not server-only

None of this is an argument against client-side applications. Collaborative editors, design tools, browser games and dense interactive data apps have very different needs, and most of their components are interactive by nature. The architecture targets the other end of the spectrum: pages where most content is naturally rendered on the server and interaction is the exception.

Static export follows from the same idea

If HTML is the default, a natural next question is why a server should run at all for pages that never change per request. Stoneware can export an application as static files and hand them to a CDN.

Stoneware application
        │
        ▼
     export
        │
        ▼
      dist/
        │
        ▼
       CDN

Documentation, marketing pages and product catalogs can often be built ahead of time and served directly from the edge. Routes that genuinely need per-request logic can stay server-rendered. The benefit is that moving a route between static and SSR does not require switching to a different programming model for the whole application.

Dynamic routes need an explicit list of paths

Static export gets harder as soon as a route has a parameter. A route like /products/[sku] could, in principle, match an unlimited number of URLs, so the exporter cannot guess which pages to generate. The framework therefore asks the route to enumerate them:

export function staticPaths() {
  return products.map(product => ({
    sku: product.sku
  }));
}

With that list, the exporter writes a real HTML file for every known SKU, for example dist/products/laptop-1/index.html. This is where framework design goes beyond rendering JSX: the framework has to understand how routes, data, the build step and the deployment target relate to each other. A practical edge case to plan for is what happens when a dynamic route has no staticPaths() at all; the framework must either fail the export loudly or keep that route server-rendered, and silently skipping it is the worst option.

Error messages are part of the renderer

At its core, a renderer takes a component and produces HTML. The difficulty is the sheer variety of children and props it must handle: text and numeric values, arrays and null, elements and nested components, signals and attributes, thrown errors, asynchronous work and, inevitably, values that cannot be rendered.

A common mistake is writing <span>{product}</span> when you meant <span>{product.name}</span>. A generic "cannot render value of type object" message tells you almost nothing about where to look. Stoneware instead describes the offending value:

Cannot render a plain object with keys: id, title, price.

and follows it with the component path that led there:

in <span>
in <Price>
in <ProductCard>
in <Home>

Listing the object's keys hints at the property you probably intended, and the component trail points to the exact file to open. A framework is judged not only by its happy path but by how quickly it helps you out of the unhappy one.

When a microbenchmark hides the real cost

Collecting that component trail originally meant wrapping many rendering operations in try/catch. An isolated microbenchmark reported that the extra cost was negligible. On a realistic page render, however, wrapping every element made rendering roughly 38% more expensive.

The explanation is familiar to anyone who benchmarks JavaScript: in a tiny, repetitive benchmark, the engine's optimizing compiler can eliminate or hoist much of the work you are trying to measure, so the number you get reflects an optimized-away version of your code.

Microbenchmarks can mislead when the runtime optimizes away the very thing being measured.

The fix was structural. Error-tracking overhead was confined to component boundaries, and individual elements used cheaper save and restore operations to maintain the path. A/B measurements on real renders then showed no meaningful difference. The broader lesson is that renderer performance is as much about not regressing while adding developer-friendly features as it is about raw speed, and that any performance claim should be validated on representative workloads.

Security defaults and pipeline order

A basic application should not require its developer to remember every security primitive before going live. Stoneware ships with defaults such as CSRF protection and Content Security Policy support.

The order of the request pipeline is deliberate:

  • CSRF protection runs first;
  • application middleware runs next;
  • route matching and rendering follow;
  • everything leaves through a single response exit.

If application middleware ran before the CSRF check, user code could end up on a path that sidesteps a framework-level security boundary, for instance by returning early or rewriting the request. Encoding that ordering in the framework, rather than documenting it and hoping everyone follows it, is exactly the kind of rule a framework should own. A single exit point also means headers such as CSP are applied consistently to every response.

Extending a strict CSP without disabling it

A restrictive policy is a good default, but real sites load analytics, payment widgets, APIs, web fonts and maps. The common failure mode is that a developer hits a blocked script and turns CSP off entirely. The better design lets you extend individual directives while keeping the rest of the default policy intact:

csp: {
  scriptSrc: ["https://www.googletagmanager.com"],
  connectSrc: ["https://www.google-analytics.com"],
  imgSrc: ["https://www.google-analytics.com"],
}

The principle is secure defaults plus an explicit, per-directive allow-list for third parties. When designing such an API, decide clearly whether a supplied source adds to the default directive or replaces it, and document the answer, because both behaviors are plausible and the difference has security consequences.

The hardest bugs live after the renderer

A framework does not end at the renderer. Code has to survive the entire journey: source, compiler, renderer, build, assets, deployment, CDN and finally the browser. Some of the most stubborn problems show up near the end of that chain, far from the code most people think of as "the framework".

Packaging island assets for Vercel

Release 0.1.8 changed how Stoneware deploys to Vercel. For that target, the generated client chunks are now embedded in the server bundle as base64 data, which makes them part of the traced bundle that the platform deploys, and they are served from /_stoneware/*.

Stoneware build
      ↓
server bundle
      ├── server code
      ├── CSS assets
      └── island assets
              ↓
          Vercel
              ↓
      /_stoneware/*

The behavior is opt-in and limited to the Vercel target. A container deployment already has the files on disk and has no reason to carry a second copy of every chunk inside the server bundle. The general takeaway is that hosting platforms differ in what they include in a deployment, so asset handling often needs per-target strategies rather than one universal answer.

Tests that prove a fix

The project has more than 500 automated tests spanning the renderer, the router, islands and signals, stylesheets and asset serving, static export, CSRF and CSP, deployment targets, error reporting, and hostile or unusual input such as path traversal attempts, binary files and malformed asset paths.

The count itself is not the point. The more useful standard is this:

A regression test should demonstrate that it would have failed before the fix.

A test that passes both before and after a change documents behavior but does not guard against the bug returning. Checking that a new test fails against the old code is a cheap habit that makes a test suite far more trustworthy.

Using a coding agent without outsourcing the design

Much of Stoneware was implemented with Claude working as a coding agent. It was particularly effective at exploring an expanding codebase, writing repetitive infrastructure, producing tests, analyzing failures, refactoring, documenting architecture and probing edge cases.

What it could not do on its own was decide what the framework should be. The hard questions were architectural:

  • For each route, is SSR or static export the right mode?
  • How should export behave for a dynamic route that lacks staticPaths()?
  • How should the renderer react when a component hands back a plain object?
  • Which locations does the build scan to find stylesheets?
  • How do generated client chunks reach the browser on Vercel?
  • Does a developer-supplied CSP source add to the default directive or override it?

An agent can research these questions and implement a chosen answer, but someone still has to challenge the design and verify the result.

Plausible is not the same as correct

A coding agent can produce a convincing implementation very quickly, and that speed is valuable. It also means it can be wrong quickly. The Vercel asset problem illustrates the loop. The first fix copied generated assets into public/, which looked sensible and passed local tests; a real deployment showed the assumption was wrong. The second attempt changed the deployment model itself. Edge-case testing then exposed another bug in that version.

That cycle is ordinary software engineering, not an AI failure. What changes is the speed of each iteration, which makes end-to-end verification in the real target environment more important, not less.

Why the runtime choice matters beyond speed

Reducing the story to "Bun is faster than Node" would miss the point, and raw speed is not a framework philosophy anyway. The more interesting experiment is designing a framework on the assumption that a modern runtime and an integrated toolchain are available from day one. Bun's combination of runtime, package management, bundling and testing makes it a convenient foundation for that kind of architectural exploration.

Where the architecture fits

The approach shines where most of a page is content and only part of it is interactive:

  • Documentation: articles and code samples are server-rendered; search, the theme switcher and copy buttons are islands.
  • Ecommerce: product details, images and SEO content are server-rendered; the cart and filters are islands.
  • Business websites: company information, services and markets are server-rendered; the contact form is an island or a server interaction.
  • Content sites: the article is server-rendered; comments and search are islands.

Where it is probably the wrong tool

If your product is effectively a desktop application running in a browser, such as a collaborative editor, a graphics tool, a game, a heavily interactive dashboard or any app where nearly every component runs on the client, the share of the page that can stay as HTML is small. The islands model then adds boundaries without saving much work, and a client-centric architecture is likely a better fit. A framework can hold a strong opinion without pretending it suits every workload.

Trying it out

Stoneware is still in its 0.1.x series at the time of writing, so expect rough edges and check the repository for current status. Planned improvements include more integrations, better diagnostics and development warnings, more examples and deployment targets, stronger documentation and more real-world applications. To scaffold a project and start the dev server:

bun create stoneware my-app
cd my-app
bun dev

A good first experiment is something small and content-heavy: a blog, a documentation site, a product catalog, a portfolio or a business site. As you build it, keep asking how much of the page really needs JavaScript.

Key takeaways

  • Treat HTML as the default output and make client JavaScript a per-component opt-in; the page becomes selectively scripted rather than an application.
  • Resumability changes the goal from faster hydration to avoiding browser work altogether, which pays off most on content-heavy pages.
  • Static export and SSR can coexist in one programming model, but dynamic routes need an explicit list of paths.
  • Invest in error messages that name the bad value and the component path; validate performance on realistic renders, not microbenchmarks alone.
  • Put security ordering, such as CSRF before middleware, into the framework, and let developers extend CSP directives instead of disabling the policy.
  • Deployment targets differ; verify asset handling end to end, and write regression tests that fail without the fix.
  • AI agents speed up implementation, but architectural decisions and real-environment verification remain human responsibilities.