Stream Scripts

Imagine a dashboard or book page where the navigation, header, and sidebar are ready immediately, but live account metrics or customer reviews depend on a slower database query. Stream scripts let you flush the static page shell and placeholder markup to the browser right away, streaming the resolved HTML chunks into the open response as asynchronous backend tasks finish.

See it in action

This is a recording of a real page served by the Bascik dev server. The skeleton and the page shell paint first, and the metric card replaces the skeleton in place when the slow work finishes (the page waits two seconds on purpose, so the swap is easy to see). There is no layout shift and no client-side fetching:

A browser loading a dashboard page. A Loading live metrics skeleton is shown first, and after about two seconds a card reading 99.99% uptime across 3 regions replaces it in place

The preview below cycles through the streaming lifecycle: the static header and pending placeholder paint immediately in the initial chunk (Stage 1), and the streamed result replaces the placeholder once the asynchronous backend task resolves (Stage 2).

See it live on Cloudflare Workers. These pages stream real chunked HTTP responses from a deployed Bascik site. Reload them and watch the placeholders resolve:

src/pages/scripts/stream.html
html
<style>
  .stream-panel {
    min-block-size: 4rem;
  }
  /* When the stream chunk arrives, CSS automatically hides the pending placeholder */
  .stream-panel:has(.result) .pending {
    display: none;
  }
</style>

<section class="stream-panel" aria-busy="true">
  <p class="pending" role="status">Loading live status…</p>
  <script data-bascik-stream>
    import { html } from "@bascik/bascik/html";

    export default async function (request, context, { signal }) {
      const response = await fetch("https://api.example.com/status", {
        signal,
      });
      const data = await response.json();
      return html`<p class="result">System online: ${data.uptime}</p>`;
    }
  </script>
</section>
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<!-- The shell is sent immediately; the result chunk arrives in the stream.
     CSS :has(.result) automatically hides the .pending placeholder. -->
<section class="stream-panel" aria-busy="true">
  <p class="pending" role="status" style="display: none;">
    Loading live status…
  </p>
  <p class="result">System online: 99.99%</p>
</section>

data-bascik-stream

When people first see this in action, the natural reaction is: Wait, how does dynamic loading work without client-side JavaScript or a framework runtime?

Here is the secret: browsers have natively supported incremental HTML parsing since the 1990s. When a web server sends standard chunked HTTP data, the browser immediately constructs the DOM, downloads stylesheets, and paints the initial HTML markup, all while keeping the network connection open.

When you tag a <script> with data-bascik-stream, Bascik leverages this native browser capability:

  1. Immediate Initial Paint: Bascik flushes the HTTP 200 headers and all static HTML preceding the stream tag immediately. The visitor sees the full page shell and your pending placeholder state instantly.
  2. Asynchronous Edge/Server Execution: The server or edge Worker executes your script asynchronously (querying databases, calling microservices, or fetching third-party APIs).
  3. Progressive Document Flush: As soon as the async task resolves, Bascik flushes the generated HTML chunk across the open connection. A script that yields flushes one chunk per yielded value.
  4. Pure CSS Transition: Standard modern CSS (like :has(.result) or container states) automatically hides the placeholder and displays the streamed HTML the moment the chunk arrives.

Zero client JavaScript is needed. No React runtime, no hydration phase, no virtual DOM diffing, and no client-side fetch() polling. It is standard HTML delivered over standard HTTP streams in source order.

html
<section class="feed-panel" aria-busy="true">
  <p class="pending" role="status">Loading live activity…</p>
  <script data-bascik-stream>
    import { html } from "@bascik/bascik/html";

    export default async function (request, context, { signal }) {
      const response = await fetch("https://api.example.com/feed", { signal });
      const items = await response.json();
      return html`<div class="result">
        ${items.map((item) => html`<p>${item.text}</p>`)}
      </div>`;
    }
  </script>
</section>

Developers familiar with React Server Components (RSC) or progressive streaming SSR will recognize the value of progressive delivery. Unlike client-hydrated component trees or client-side DOM reconciliation runtimes, Bascik streams ordinary vanilla HTML in one document response in source order. No Bascik client library is injected into the browser.

Choosing script execution modes:

  • Build scripts run once at build time (data-bascik-build) for static content.
  • Server scripts run on each request (data-bascik-server) and buffer output before committing headers, returning an HTTP 500 error page if an error occurs.
  • Stream scripts flush headers and the static shell immediately, then stream HTML chunks progressively over an open connection.

The scripts overview compares every script directive, including data-bascik-build-once and data-bascik-routes, side by side.

Note: Request-time streaming requires something that executes code per request: the Bascik dev server, the production server, or a serverless function generated by a build --target such as the Cloudflare Adapter. A static-only CDN serving pre-rendered HTML cannot stream anything. For security guidelines when interpolating user data, see Escaping and injection.

TypeScript

A stream script can be TypeScript the way a server script can: a src= .ts module, or an inline block marked type="text/typescript". Bascik strips the types before it runs, on Node and in the Cloudflare Worker, and errors keep the source's lines. A TypeScript mistake fails the build, not the request, with the same message on Node and Cloudflare.

The Output is the whole <body> that bascik --server streamed for GET /stock, reformatted for reading. The list arrives in the slot the script left, after the heading has already been sent. There is no Preview because this docs site is static and runs no stream script.

src/pages/stock.html
html
<!-- src/pages/stock.html -->
<!doctype html>
<html lang="en">
  <head>
    <title>Stock</title>
  </head>
  <body>
    <h1>Copies left</h1>
    <script data-bascik-stream type="text/typescript">
      import { html, type SafeHtml } from "@bascik/bascik/html";

      interface Item {
        title: string;
        left: number;
      }

      export default async (): Promise<SafeHtml> => {
        const items: Item[] = [
          { title: "Refactoring the Monolith", left: 3 },
          { title: "Practical Type Systems", left: 12 },
        ];
        return html`<ul>
          ${items.map((item) => html`<li>${item.title}: ${item.left} left</li>`)}
        </ul>`;
      };
    </script>
  </body>
</html>
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<h1>Copies left</h1>
<ul>
  <li>Refactoring the Monolith: 3 left</li>
  <li>Practical Type Systems: 12 left</li>
</ul>

Execution Model & Request Flow

Stream scripts share the standard server handler signature: (request, context, { signal }). With stream scripts, one key architectural difference applies: the server does not wait for stream scripts before committing HTTP response headers.

html
<script data-bascik-stream>
  import { html } from "@bascik/bascik/html";

  export default async function (request, context, { signal }) {
    const res = await fetch("https://api.example.com/user", {
      headers: { cookie: request.headers.get("cookie") || "" },
      signal,
    });
    const user = await res.json();
    return html`<article><h3>Welcome back, ${user.name}</h3></article>`;
  }
</script>

The Mixed-Page Ordering Rule

When a page contains both data-bascik-server and data-bascik-stream blocks:

  1. Phase 1 (Preparation): All data-bascik-server scripts on the page execute first. If any server script fails under scripts.onServerScriptError: 'error', the response is aborted and an HTTP 500 status is returned.
  2. Phase 2 (Commit & Early Flush): HTTP headers and all static HTML bytes up to the first data-bascik-stream tag are committed and sent immediately to the browser.
  3. Phase 3 (Streaming Output): Static segments and stream script outputs are emitted in strict document source order. While the server executes stream jobs concurrently with bounded concurrency, chunks are flushed sequentially so the HTML stream remains valid.

Placement tip: A slow data-bascik-server script on a page delays the initial response commit for the entire page. If a data operation is slow or latency-sensitive, use data-bascik-stream instead. See a live demonstration of this execution flow on our mixed server and stream demo.

A page with both server and stream scripts
  1. Prepare Every data-bascik-server script runs first. A failure can still become a 500.
  2. Commit and flush Headers and the HTML before the first stream script go to the browser at once.
  3. Stream Stream scripts run concurrently, and their output is flushed in source order.

Placeholders That Do Not Shift Layout

Whatever HTML you write before a <script data-bascik-stream> tag is sent in the initial chunk and displayed while the script executes. Bascik does not inject synthetic wrappers or client loaders. The placeholder gives way to the streaming result through standard HTML and CSS.

Recipe A: Reserved Box (Default Pattern)

Wrap the placeholder and script in a container that reserves its typical dimensions using min-block-size for text blocks or aspect-ratio for media elements. Include aria-busy="true" on the container and role="status" on the placeholder text so assistive technologies announce the pending state:

html
<!-- src/components/account-panel.html -->
<style>
  .panel {
    display: grid;
    min-block-size: 8rem;
  }
  .panel > * {
    grid-area: 1 / 1;
  }
  .pending {
    color: var(--text-muted, #6b7280);
  }
  .result {
    background: var(--surface, #ffffff);
  }
</style>

<section class="panel" aria-busy="true">
  <p class="pending" role="status">Loading account details…</p>
  <script data-bascik-stream>
    import { html } from "@bascik/bascik/html";

    export default async function (request, context, { signal }) {
      const response = await fetch("https://api.example.com/account", {
        signal,
      });
      const account = await response.json();
      return html`<article class="result"><h3>${account.name}</h3></article>`;
    }
  </script>
</section>

How Visual Swap Works

Because Bascik adds no client runtime, previously flushed DOM nodes cannot be removed without client code. The streaming result is appended where the <script> tag lived in the DOM. The visual swap is achieved with author CSS:

  • Pattern 1: One-Cell CSS Grid Stack (Recommended): Set display: grid and place children in grid-area: 1 / 1. The arrived .result paints directly over the .pending placeholder.
  • Pattern 2: CSS :has() Parent Selector: Use .panel:has(> .result) > .pending { display: none; } to hide the placeholder element once the .result child arrives in the DOM.

Accessibility consideration: CSS visual overlay or :has() hiding visually swaps elements, but CSS alone cannot change the container's aria-busy attribute. For static streaming where no client script is desired, avoid leaving an indefinite aria-busy="true" container if assistive technology requires state settlement, or add a small progressive enhancement script to toggle attributes once loaded.

Recipe B: Skeleton Shimmer

Add a loading animation with pure CSS keyframes, accounting for prefers-reduced-motion:

html
<style>
  @keyframes shimmer {
    0% {
      opacity: 0.5;
    }
    50% {
      opacity: 0.85;
    }
    100% {
      opacity: 0.5;
    }
  }
  .skeleton-container {
    min-block-size: 6rem;
  }
  .skeleton {
    min-block-size: 6rem;
    background: var(--surface-subtle, #f3f4f6);
    border-radius: 6px;
  }
  @media (prefers-reduced-motion: no-preference) {
    .skeleton {
      animation: shimmer 1.5s ease-in-out infinite;
    }
  }
  /* Automatically hide the skeleton once the streamed result chunk arrives */
  .skeleton-container:has(.result) .skeleton {
    display: none;
  }
</style>

<div class="skeleton-container" aria-busy="true">
  <div class="skeleton" role="status" aria-label="Loading data"></div>
  <script data-bascik-stream>
    import { html } from "@bascik/bascik/html";

    export default async function (request, context, { signal }) {
      const res = await fetch("https://api.example.com/details", { signal });
      const data = await res.json();
      return html`<div class="result"><p>${data.details}</p></div>`;
    }
  </script>
</div>

In Bascik streaming, source order is network delivery order: after all data-bascik-server scripts finish, markup before the first <script data-bascik-stream> tag is flushed without waiting for stream results. Static markup after a slow stream waits too; later jobs finishing first cannot bypass it.

As an optional layout technique, place independent shell content such as help, a sidebar, or the footer before the first stream tag in source. Use Flexbox order or CSS Grid grid-template-areas on a shared layout container to display it below the streamed content. The homepage and Cloudflare adapter stream demo use this early-paint footer pattern.

This lets the browser paint the shell while streams are pending, without client-side relocation or a Bascik runtime. Reserve space for expected results with min-block-size or aspect-ratio when layout stability matters: source reordering alone does not prevent layout shifts. Deferred and module scripts may still wait for parsing to finish, so early markup does not guarantee early interactivity.

Keep logical source order by default. Choose this option only when both the source reading order and visual layout remain understandable. CSS changes neither screen-reader order nor keyboard tab order; avoid it for sequential forms, instructions, or controls whose order matters. See W3C CSS Grid Placement. Authentication and authorization must still run before streaming, and protected content must not become unconditional static shell markup.

To verify the technique, deliberately hold a stream unresolved and check that the intended shell bytes arrive before releasing it. Check desktop and mobile geometry with JavaScript disabled, keyboard navigation, and success, empty, and error results. The following examples illustrate layout options to adapt, not a requirement to reorder every streaming page.

When rendering multiple independent streaming sections (like cards in a dashboard), place all skeleton placeholders first in the initial HTML chunk before the streaming script slots, and assign them to named CSS grid areas. As each stream chunk resolves, its arrived card lands in the corresponding grid area, and CSS :has() swaps out only that specific skeleton:

html
<!-- src/pages/dashboard.html -->
<style>
  .dash-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-areas: "slot1 slot2 slot3";
    gap: 1rem;
  }
  .cell-1 {
    grid-area: slot1;
  }
  .cell-2 {
    grid-area: slot2;
  }
  .cell-3 {
    grid-area: slot3;
  }

  /* In-place swap: when result markup arrives in a slot, hide its matching skeleton */
  .dash-grid:has(.result-1) .cell-1.skeleton {
    display: none;
  }
  .dash-grid:has(.result-2) .cell-2.skeleton {
    display: none;
  }
  .dash-grid:has(.result-3) .cell-3.skeleton {
    display: none;
  }
</style>

<div class="dash-grid">
  <!-- All 3 skeletons delivered in the very first byte flush -->
  <div class="skeleton cell-1" role="status"><p>Loading Metrics…</p></div>
  <div class="skeleton cell-2" role="status"><p>Loading Feed…</p></div>
  <div class="skeleton cell-3" role="status"><p>Loading Database…</p></div>

  <!-- Streaming scripts resolve independently in source order -->
  <div class="cell-1">
    <script data-bascik-stream>
      import { html } from "@bascik/bascik/html";
      export default async function (request, context, { signal }) {
        const stats = await fetchMetrics({ signal });
        return html`<article class="card result-1">
          <h3>${stats.uptime}</h3>
        </article>`;
      }
    </script>
  </div>

  <div class="cell-2">
    <script data-bascik-stream>
      import { html } from "@bascik/bascik/html";
      export default async function (request, context, { signal }) {
        const feed = await fetchFeed({ signal });
        return html`<article class="card result-2">
          <h3>${feed.rate}</h3>
        </article>`;
      }
    </script>
  </div>

  <div class="cell-3">
    <script data-bascik-stream>
      import { html } from "@bascik/bascik/html";
      export default async function (request, context, { signal }) {
        const db = await fetchDb({ signal });
        return html`<article class="card result-3">
          <h3>${db.status}</h3>
        </article>`;
      }
    </script>
  </div>
</div>

Option 2: Full Dashboard Layout (Grid Template Areas)

html
<!-- src/pages/dashboard-layout.html -->
<style>
  .dash-layout {
    display: grid;
    grid-template-columns: 14rem 1fr;
    grid-template-areas:
      "nav metrics"
      "nav billing"
      "nav footer";
  }
  .nav {
    grid-area: nav;
  }
  .metrics {
    grid-area: metrics;
    min-block-size: 8rem;
  }
  .billing {
    grid-area: billing;
    min-block-size: 12rem;
  }
  .foot {
    grid-area: footer;
  }
</style>

<div class="dash-layout">
  <!-- Fast, static elements delivered in the initial chunk -->
  <nav class="nav"><a href="/dashboard">Dashboard</a></nav>
  <footer class="foot"><p>&copy; 2026 Acme Corp</p></footer>

  <!-- Heavy streaming sections placed later in HTML source -->
  <section class="metrics" aria-busy="true">
    <p class="pending" role="status">Loading live metrics…</p>
    <script data-bascik-stream>
      import { html } from "@bascik/bascik/html";

      export default async function (request, context, { signal }) {
        const res = await fetch("https://api.example.com/metrics", { signal });
        const stats = await res.json();
        return html`<div class="result"><p>${stats.summary}</p></div>`;
      }
    </script>
  </section>

  <section class="billing" aria-busy="true">
    <p class="pending" role="status">Loading billing history…</p>
    <script data-bascik-stream>
      import { html } from "@bascik/bascik/html";

      export default async function (request, context, { signal }) {
        const res = await fetch("https://api.example.com/billing", { signal });
        const bills = await res.json();
        return html`<div class="result"><p>${bills.total}</p></div>`;
      }
    </script>
  </section>
</div>

Option 3: Flexbox Column Order

html
<!-- src/pages/report.html -->
<style>
  body {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  }
  header {
    order: 1;
  }
  main {
    order: 2;
  }
  footer {
    order: 3;
    margin-top: auto;
  }
</style>

<!-- Header AND Footer are delivered in the first chunk -->
<header><h1>Quarterly Analytics</h1></header>
<footer class="site-footer"><p>Generated by Bascik</p></footer>

<main>
  <!-- Streaming content delivers asynchronously into the middle slot -->
  <section class="revenue" aria-busy="true">
    <div class="skeleton" role="status">Loading revenue figures…</div>
    <script data-bascik-stream>
      import { html } from "@bascik/bascik/html";

      export default async function (request, context, { signal }) {
        const res = await fetch("https://api.example.com/revenue", { signal });
        const data = await res.json();
        return html`<div class="result"><h2>${data.total}</h2></div>`;
      }
    </script>
  </section>
</main>

Option 4: Zero-Placeholder Progressive Append Flow

If you want content to append progressively as it streams in without showing placeholders upfront, simply omit the placeholder markup before the <script data-bascik-stream> tags. The browser constructs and renders each element as soon as its stream chunk is flushed:

html
<!-- src/pages/feed.html -->
<div class="feed-grid">
  <!-- Items stream and append into the grid in source order with no placeholders -->
  <script data-bascik-stream>
    import { html } from "@bascik/bascik/html";
    import { fetchPrimaryFeed } from "@/lib/feeds.ts";

    export default async function (request, context, { signal }) {
      const item = await fetchPrimaryFeed({ signal });
      return html`<article class="feed-card"><h3>${item.title}</h3></article>`;
    }
  </script>
  <script data-bascik-stream>
    import { html } from "@bascik/bascik/html";
    import { fetchSecondaryFeed } from "@/lib/feeds.ts";

    export default async function (request, context, { signal }) {
      const item = await fetchSecondaryFeed({ signal });
      return html`<article class="feed-card"><h3>${item.title}</h3></article>`;
    }
  </script>
</div>

To append many items from one source, one script that yields each item does the same job without a script tag per item.

Yield rows as they arrive

A stream script that returns sends its markup in one piece, when all of it is ready. When the markup is a list whose rows come from a slow source one at a time (a database cursor, a paged API, several calls), write the script as an async generator: export default async function*, with a yield for each row. Bascik sends every yielded value to the browser as soon as the script yields it, in the order it yields them, into the place the script tag was. It is one script and one response, and still no client JavaScript.

This page lists books from a source that takes a moment per book:

html
<!-- src/pages/reading-list.html -->
<!doctype html>
<html lang="en">
  <head>
    <title>Reading list</title>
  </head>
  <body>
    <h1>Reading list</h1>
    <ul>
      <script data-bascik-stream type="text/typescript">
        import { html, type SafeHtml } from "@bascik/bascik/html";
        import { loadBooks } from "@/lib/books.ts";

        export default async function* (): AsyncGenerator<SafeHtml> {
          for await (const book of loadBooks()) {
            yield html`<li>${book.title} by ${book.author}</li>`;
          }
        }
      </script>
    </ul>
    <footer>Updated daily</footer>
  </body>
</html>

The source is an async generator too. Any async iterable works the same way, such as a database driver's cursor or a paged API client:

ts
// src/lib/books.ts
export interface Book {
  title: string;
  author: string;
}

const BOOKS: Book[] = [
  { title: "Refactoring the Monolith", author: "Grace Lin" },
  { title: "Practical Type Systems", author: "Ada Okafor" },
  { title: "Shipping Small Changes", author: "Sam Rivera" },
];

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// Stands in for a slow source: a database cursor, a paged API.
export async function* loadBooks(): AsyncGenerator<Book> {
  for (const book of BOOKS) {
    await wait(400);
    yield book;
  }
}

These are the chunks bascik --server sent for GET /reading-list, with the time each one arrived. The heading is in the browser before the first book exists, and each book follows 400 milliseconds after the last:

text
34 ms  <!doctype html> ... <h1>Reading list</h1> <ul>
 435 ms  <li>Refactoring the Monolith by Grace Lin</li>
 835 ms  <li>Practical Type Systems by Ada Okafor</li>
1237 ms  <li>Shipping Small Changes by Sam Rivera</li> </ul> <footer>Updated daily</footer> ... </html>

What a script can yield

  • Each yielded value is printed like a returned one. A string, an html value, or an array of them, by the server script rules; null, undefined, and false print nothing.
  • Yield whole elements. Put a complete element, or several, in each yield, never half a tag. Inside a component, the scoped class names are applied to each yielded value on its own, and component tags are rendered one yielded value at a time.
  • Delegate with yield*. yield* otherRows() yields everything another generator yields. Yielding the generator itself (yield otherRows()) is refused, because a generator is not markup.
  • A returned value is sent last. What the generator returns with return markup is sent as its final value, so an early return for an empty list still shows its message.
  • Any async iterable of text works. A script may return one instead of being a generator, as long as each value it gives holds whole elements. A response body does not: it arrives in slices of bytes that end anywhere, so read it with await response.text() or split it into elements yourself. Bytes (a Uint8Array) are refused, never printed as numbers.
  • A sync generator works too. export default function* () is sent one chunk per yield; use it when nothing in the script waits.
  • The handler signature is unchanged. The generator receives (request, context, { signal }) like every other server or stream script.
  • A server script may yield as well. In a data-bascik-server script the yielded values are joined with no separator before the page is sent, because nothing of a server script is streamed.

Source order still decides delivery order: the markup after a script that yields, and every stream script after it, waits until the generator finishes. Put a long list last in source, and use Recipe C to show content below it first.

Skeleton rows that fill in one at a time

The placeholder recipes work unchanged with a script that yields, because the browser adds each row to the page as it arrives. This component stacks the real list on top of a list of skeleton rows in one grid cell, so each row that arrives covers one skeleton row. The script's last yield is an empty, hidden marker, and :has(.done) hides the skeleton rows that are left when there were fewer books than skeletons:

html
<!-- src/components/new-arrivals/new-arrivals.html -->
<style>
  .arrivals {
    display: grid;
    min-block-size: 10rem;
  }
  /* Both lists share one grid cell, so a row lands on a skeleton row. */
  .arrivals > * {
    grid-area: 1 / 1;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  /* The real rows get a layer of their own, above the skeleton rows. */
  .rows {
    position: relative;
    z-index: 1;
  }
  .skeletons > li,
  .row {
    box-sizing: border-box;
    block-size: 2.5rem;
  }
  .skeletons > li {
    background: var(--surface-subtle, #f3f4f6);
  }
  .row {
    background: var(--surface, #ffffff);
  }
  /* The last yield: hide the skeleton rows that are left over. */
  .arrivals:has(.done) > .skeletons {
    display: none;
  }
</style>

<section class="arrivals" aria-busy="true" data-testid="new-arrivals">
  <ul class="skeletons" role="status" aria-label="Loading new arrivals">
    <li></li>
    <li></li>
    <li></li>
    <li></li>
  </ul>
  <ul class="rows">
    <script data-bascik-stream type="text/typescript">
      import { html, type SafeHtml } from "@bascik/bascik/html";
      import { loadBooks } from "@/lib/books.ts";

      export default async function* (): AsyncGenerator<SafeHtml> {
        for await (const book of loadBooks()) {
          yield html`<li class="row">${book.title} by ${book.author}</li>`;
        }
        yield html`<li class="done" hidden></li>`;
      }
    </script>
  </ul>
</section>

Give the rows and the skeleton rows the same fixed block-size, and reserve the list's size on the container, so nothing below the list moves as rows arrive. Keep the position: relative and z-index on the list of real rows: a skeleton with a shimmer animates its opacity, which makes it a stacking layer of its own, and the browser would paint it on top of rows that have no layer, so the rows would flash with the shimmer. To hide one status line when the first row lands instead, use the single-placeholder rule from Recipe A with the row's class: .arrivals:has(.row) > .pending { display: none; }. See it running on the rows demo.

Rows in the order they finish

A for await loop over one source yields rows in the source's order. To start several independent lookups at once and show each as soon as it answers, keep the pending promises in a Map and yield whichever settles first:

html
<!-- src/pages/fastest-first.html -->
<ul>
  <script data-bascik-stream type="text/typescript">
    import { html, type SafeHtml } from "@bascik/bascik/html";
    import { loadBook, type Book } from "@/lib/books.ts";

    const TITLES = [
      "Refactoring the Monolith",
      "Practical Type Systems",
      "Shipping Small Changes",
    ];

    export default async function* (): AsyncGenerator<SafeHtml> {
      // Start every lookup now, then yield each as it settles.
      const pending = new Map<string, Promise<Book>>(
        TITLES.map((title) => [title, loadBook(title)]),
      );
      while (pending.size > 0) {
        const book = await Promise.race(pending.values());
        pending.delete(book.title);
        yield html`<li>${book.title} by ${book.author}</li>`;
      }
    }
  </script>
</ul>

All three lookups run at the same time, and the page lists the books fastest first.

Release what the script holds

When the visitor leaves before the list is complete, or the script times out, Bascik ends the generator, so its finally blocks run. Close a cursor or a connection there, and pass signal to anything that can be canceled:

html
<ul>
  <script data-bascik-stream type="text/typescript">
    import { html, type SafeHtml } from "@bascik/bascik/html";
    import { openOrders } from "@/lib/orders.ts";

    export default async function* (
      request: Request,
      context: unknown,
      { signal }: { signal: AbortSignal },
    ): AsyncGenerator<SafeHtml> {
      const cursor = await openOrders({ signal });
      try {
        for await (const order of cursor) {
          yield html`<li>${order.id}: ${order.total}</li>`;
        }
      } finally {
        await cursor.close();
      }
    }
  </script>
</ul>

A generator that is waiting on something when it is ended reaches its finally block only when that wait settles, which is why the wait should take the signal.

Returning component tags

A stream script can return the tags of your own components, exactly like a server script. Each tag is rendered at request time with the component's markup, props, slots, and scoped CSS, and the result is streamed into its slot. Nothing is imported or configured. The security guarantees and the full rules are in Returning component tags on the scripts overview; this section covers what is different when streaming.

See it live on Cloudflare Workers. The streamed catalog on the Cloudflare demo site streams <book-card> tiles in batches, and the book catalog renders the same cards from a server script.

Stream cards in batches

This page streams the <book-card> from Render a card per search result in two batches, one per data-bascik-stream script. Bascik flushes the slots in source order. The card's stylesheet is sent once, as one <style> at the start of the first slot in document order that renders a card, so the second batch carries none. Every card's client script has the same text, so one Content-Security-Policy hash covers both batches. Here each batch returns at once so the Output is the same on every run; a real batch arrives when its data resolves, such as a search API that takes a moment.

The Source tabs show the page and the card's files. The Output is the whole body bascik --server streamed for GET /new-arrivals, reformatted for reading. The Preview is drawn by this docs site's static build from the same three tags, because the docs site has no server to run a stream script.

Run this on Cloudflare Workers: /catalog-stream.

src/pages/new-arrivals.html

Refactoring the Monolith

Grace Lin

Best seller

Practical Type Systems

Ada Okafor

New

Shipping Small Changes

Sam Rivera

Paperback
html
<!-- src/pages/new-arrivals.html -->
<!doctype html>
<html lang="en">
  <head>
    <title>New arrivals</title>
  </head>
  <body>
    <div class="results">
      <script data-bascik-stream>
        export default async function () {
          return (
            `<book-card data-bascik-prop-title="Refactoring the Monolith" data-bascik-prop-author="Grace Lin">Best seller</book-card>` +
            `<book-card data-bascik-prop-title="Practical Type Systems" data-bascik-prop-author="Ada Okafor">New</book-card>`
          );
        }
      </script>
      <script data-bascik-stream>
        export default async function () {
          return `<book-card data-bascik-prop-title="Shipping Small Changes" data-bascik-prop-author="Sam Rivera"></book-card>`;
        }
      </script>
    </div>
  </body>
</html>
html
<!-- src/components/book-card/book-card.html -->
<article class="card" data-testid="book-card">
  <h3 class="title" data-bascik-prop-title>Untitled book</h3>
  <p class="author" data-bascik-prop-author>Unknown author</p>
  <span class="badge"><span data-bascik-slot>Paperback</span></span>
  <button
    class="save"
    type="button"
    aria-pressed="false"
    data-testid="book-card-save"
  >
    Save
  </button>
</article>
<script src="book-card.js"></script>
css
/* src/components/book-card/book-card.css */
.card {
  display: grid;
  gap: 6px;
  padding: 16px;
  border: 1px solid #3a3d40;
  border-radius: 12px;
  background: #242628;
  color: #f0f1f2;
}

.title {
  margin: 0;
  font-size: 1rem;
}

.author {
  margin: 0;
  color: #8d929e;
}

.badge {
  justify-self: start;
  padding: 2px 10px;
  border-radius: 999px;
  background: rgba(211, 255, 141, 0.15);
  color: #d3ff8d;
  font-size: 0.75rem;
  font-weight: 600;
}

.save {
  justify-self: start;
  padding: 4px 12px;
  border: 1px solid #3a3d40;
  border-radius: 8px;
  background: transparent;
  color: #f0f1f2;
  cursor: pointer;
}

.save[aria-pressed="true"] {
  border-color: rgba(211, 255, 141, 0.6);
  color: #d3ff8d;
}
js
// src/components/book-card/book-card.js
// One click listener serves every card on the page. The text is the same in
// every instance (no scoped id), so one CSP hash covers cards a server script
// renders at request time too.
if (!document.documentElement.hasAttribute("data-book-card-save")) {
  document.documentElement.setAttribute("data-book-card-save", "");
  document.addEventListener("click", (event) => {
    const save = event.target.closest(".save");
    if (save === null) return;
    const saved = save.getAttribute("aria-pressed") !== "true";
    save.setAttribute("aria-pressed", String(saved));
    save.textContent = saved ? "Saved" : "Save";
  });
}
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<!-- src/pages/new-arrivals.html -->
<!doctype html>
<html lang="en">
  <head>
    <title>New arrivals</title>
  </head>
  <body>
    <div class="results">
      <style>
        .bascik__book-card__card {
          display: grid;
          gap: 6px;
          padding: 16px;
          border: 1px solid #3a3d40;
          border-radius: 12px;
          background: #242628;
          color: #f0f1f2;
        }

        .bascik__book-card__title {
          margin: 0;
          font-size: 1rem;
        }

        .bascik__book-card__author {
          margin: 0;
          color: #8d929e;
        }

        .bascik__book-card__badge {
          justify-self: start;
          padding: 2px 10px;
          border-radius: 999px;
          background: rgba(211, 255, 141, 0.15);
          color: #d3ff8d;
          font-size: 0.75rem;
          font-weight: 600;
        }

        .bascik__book-card__save {
          justify-self: start;
          padding: 4px 12px;
          border: 1px solid #3a3d40;
          border-radius: 8px;
          background: transparent;
          color: #f0f1f2;
          cursor: pointer;
        }

        .bascik__book-card__save[aria-pressed="true"] {
          border-color: rgba(211, 255, 141, 0.6);
          color: #d3ff8d;
        }</style
      ><!-- src/components/book-card/book-card.html -->
      <article class="bascik__book-card__card" data-testid="book-card">
        <h3 class="bascik__book-card__title">Refactoring the Monolith</h3>
        <p class="bascik__book-card__author">Grace Lin</p>
        <span class="bascik__book-card__badge">Best seller</span>
        <button
          class="bascik__book-card__save"
          type="button"
          aria-pressed="false"
          data-testid="book-card-save"
        >
          Save
        </button>
      </article>
      <script>
        (function () {
          // src/components/book-card/book-card.js
          // One click listener serves every card on the page. The text is the same in
          // every instance (no scoped id), so one CSP hash covers cards a server script
          // renders at request time too.
          if (!document.documentElement.hasAttribute("data-book-card-save")) {
            document.documentElement.setAttribute("data-book-card-save", "");
            document.addEventListener("click", (event) => {
              const save = event.target.closest(".bascik__book-card__save");
              if (save === null) return;
              const saved = save.getAttribute("aria-pressed") !== "true";
              save.setAttribute("aria-pressed", String(saved));
              save.textContent = saved ? "Saved" : "Save";
            });
          }
        })();
        //# sourceURL=src/components/book-card/book-card.js
      </script>
      <!-- src/components/book-card/book-card.html -->
      <article class="bascik__book-card__card" data-testid="book-card">
        <h3 class="bascik__book-card__title">Practical Type Systems</h3>
        <p class="bascik__book-card__author">Ada Okafor</p>
        <span class="bascik__book-card__badge">New</span>
        <button
          class="bascik__book-card__save"
          type="button"
          aria-pressed="false"
          data-testid="book-card-save"
        >
          Save
        </button>
      </article>
      <script>
        (function () {
          // src/components/book-card/book-card.js
          // One click listener serves every card on the page. The text is the same in
          // every instance (no scoped id), so one CSP hash covers cards a server script
          // renders at request time too.
          if (!document.documentElement.hasAttribute("data-book-card-save")) {
            document.documentElement.setAttribute("data-book-card-save", "");
            document.addEventListener("click", (event) => {
              const save = event.target.closest(".bascik__book-card__save");
              if (save === null) return;
              const saved = save.getAttribute("aria-pressed") !== "true";
              save.setAttribute("aria-pressed", String(saved));
              save.textContent = saved ? "Saved" : "Save";
            });
          }
        })();
        //# sourceURL=src/components/book-card/book-card.js
      </script>

      <!-- src/components/book-card/book-card.html -->
      <article class="bascik__book-card__card" data-testid="book-card">
        <h3 class="bascik__book-card__title">Shipping Small Changes</h3>
        <p class="bascik__book-card__author">Sam Rivera</p>
        <span class="bascik__book-card__badge">Paperback</span>
        <button
          class="bascik__book-card__save"
          type="button"
          aria-pressed="false"
          data-testid="book-card-save"
        >
          Save
        </button>
      </article>
      <script>
        (function () {
          // src/components/book-card/book-card.js
          // One click listener serves every card on the page. The text is the same in
          // every instance (no scoped id), so one CSP hash covers cards a server script
          // renders at request time too.
          if (!document.documentElement.hasAttribute("data-book-card-save")) {
            document.documentElement.setAttribute("data-book-card-save", "");
            document.addEventListener("click", (event) => {
              const save = event.target.closest(".bascik__book-card__save");
              if (save === null) return;
              const saved = save.getAttribute("aria-pressed") !== "true";
              save.setAttribute("aria-pressed", String(saved));
              save.textContent = saved ? "Saved" : "Save";
            });
          }
        })();
        //# sourceURL=src/components/book-card/book-card.js
      </script>
    </div>
  </body>
</html>

Rules when streaming

The rules are the ones for server scripts. These are the ones that matter most when streaming:

  • Script output is HTML. Component tags in it render with the same power as your own markup, and the component's own scripts run with the current request. Build it with html, which escapes every value you interpolate, and sanitize HTML from a CMS or an API before inserting it with raw().
  • A script that yields is rendered one yielded value at a time. Each value's component tags are rendered when that value is sent, so cards reach the browser one by one. The budgets for one script output (1000 component instances, 4 MiB of rendered HTML) apply to each yielded value; the limit of 64 nested component scripts counts the whole response, so a long list of cards that each run their own script needs a higher scripts.renderLimits.maxNestedScripts. A yielded value over a budget is dropped under 'warn' and 'ignore', and ends the script's output under 'error'.
  • A nested stream script resolves with its parent. A data-bascik-stream or data-bascik-server script inside a returned component runs once per rendered instance and receives no props from the usage tag. Its output is spliced into the instance before the parent's slot is written, so the slot arrives complete, in one piece, and the page's stream order is unchanged. The nested script gets what is left of the parent's scripts.timeout.
  • CSS arrives before the first instance. Each component's stylesheet is sent as one <style> block in the body, at the start of the first slot in document order that renders an instance of it, once per response for all of the page's stream scripts, whatever order the slots finish in. A page that also returns the component from a server script sends it once more for the server part.
  • Failures leave the slot empty, never a 500. An output over a budget (1000 component instances, 4 MiB of rendered HTML, or rereading more than 16 times its length plus 33,554,432 characters per output, 64 nested component scripts per response, 4 levels deep) leaves this slot empty and logs a message naming the route and the component file. A nested script that throws empties this slot under scripts.onServerScriptError: 'error'; under 'warn' or 'ignore' only the nested script's part of the card is empty. Either way the rest of the document streams on.
  • Tags that are not rendered stay as written. A tag listed in components.external with no component file, a misspelled tag, and a component that cannot be rendered at request time all reach the browser as written; the dev server logs the last two once per route when at least one component can be rendered at request time. See Request-Time Component Rendering for the components that cannot be rendered and how to restructure them.

The streamed catalog demo on Cloudflare Workers streams batches of the same cards. The full list of rules, including base paths and client scripts, is in Rules for returned tags.

Error Handling & Headers

Error Policy

Because streaming begins after HTTP 200 headers are already committed, a data-bascik-stream failure cannot convert the response into an HTTP 500 error page:

  • Under scripts.onServerScriptError: 'error', the error is logged to stderr with source line remapping, the script slot receives an empty string, and the rest of the document stream completes cleanly.
  • Under scripts.onServerScriptError: 'warn', a warning is logged to stderr and the stream continues.
  • Under scripts.onServerScriptError: 'ignore', the failure is ignored silently.

The author's placeholder remains visible on error, keeping page layout intact.

A stream script that resolves its last chunk after scripts.timeout has passed, even inside its own abort listener, is a timeout under the policy above, not a late chunk.

A script that yields

A script that yields has sent part of its output by the time something goes wrong, so the policy applies to what is left:

  • A failure part-way keeps the rows already sent. A throw inside the generator ends its output there. It is logged under the policy above, and the rest of the document streams on. Under 'warn' and 'ignore' the page is the same, with a warning or no log.
  • scripts.timeout counts the script's own time. The clock runs while the script is producing its next value and stops while Bascik writes a value to the visitor, so a slow connection does not time a script out. A connection that stops reading altogether is closed by the server (http.timeouts.drain on the built-in server), which ends the script. A script that spends longer than scripts.timeout in total between its yields is ended as a timeout, its signal aborts, and the rows already sent stay.
  • A yielded value that is not markup is that one value's failure. In dev it is a failure under the policy above, with a message that names the route, the script's file and line, and what was yielded; under 'error' the output ends there. In production the value is skipped, the rest is sent, and a warning is logged once per script.
  • A server script that yields fails as a whole. Its values are joined before the page is sent, so a failure under 'error' is still an HTTP 500 and no partial list is sent.

A stream script's return value follows the server script rules: an array is joined with no separator, so items.map((item) => html`...`) can be returned directly, and a value that is not markup (a plain object, a Response, a function, an array holding a Promise) leaves the slot empty. In dev that is a failure under the policy above, logged with the route, the script's file and line, and what was returned; in production it is a warning logged once per script.

HTTP Headers & Caching Consequences

Pages containing streaming scripts automatically adjust HTTP transport headers:

  • HTTP/1.1 Framing: Uses Transfer-Encoding: chunked according to RFC 9112 Section 7.1.
  • HTTP/2 Framing: Uses native multiplexed DATA frames according to RFC 9113 Section 8.1. Transfer-Encoding is prohibited in HTTP/2 (RFC 9113 Section 8.2.2).
  • Content-Length & ETag: Omitted because response byte length is unknown when headers are committed.
  • Cache-Control: Automatically set to private, no-store to prevent intermediary proxy caching of partial streaming responses (RFC 9111). security.privateDynamicPages: false removes it.
  • HEAD Requests: HEAD requests execute through the buffered planner to compute exact Content-Length headers without streaming the body.

Client Disconnect & Backpressure

  • Backpressure Handling: When a downstream TCP socket buffer fills, Bascik's response sink pauses execution of subsequent stream segments until the socket emits drain.
  • One value at a time: A script that yields is asked for its next value only after the last one was written, so a slow connection also slows the script instead of filling memory. A script that yields and sits ahead of the write position runs to its first yield and waits there.
  • Cooperative Cancellation: If a client closes the connection or navigates away before stream completion, the AbortController triggers. The passed signal aborts ongoing fetch() requests and database queries that consume the signal. Synchronous operations or uninstrumented database clients must be handled accordingly. A script that yields is ended as well, so its finally blocks run.

Next: See Server Scripts for buffered per-request execution, or read Production Server for server deployment options.