Stream Scripts

Imagine a dashboard or product 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

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

src/pages/stream-scripts.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 { escape } from '@/lib/server.ts';

    export default async function (request, context, { signal }) {
      const response = await fetch('https://api.example.com/status', { signal });
      const data = await response.json();
      return `<p class="result">System online: ${escape(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.
  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 { escape } from '@/lib/server.ts';

    export default async function (request, context, { signal }) {
      const response = await fetch('https://api.example.com/feed', { signal });
      const items = await response.json();
      return `<div class="result">${items.map(item => `<p>${escape(item.text)}</p>`).join('')}</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.

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. Test the behavior in production on our live Cloudflare adapter deployment.

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 { escape } from '@/lib/server.ts';

  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 `<article><h3>Welcome back, ${escape(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.

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 { escape } from '@/lib/server.ts';

    export default async function (request, context, { signal }) {
      const response = await fetch('https://api.example.com/account', { signal });
      const account = await response.json();
      return `<article class="result"><h3>${escape(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 { escape } from '@/lib/server.ts';

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

In Bascik streaming, source order is network delivery order: all markup before the first <script data-bascik-stream> tag arrives in the initial chunk (Time-to-First-Byte), while streaming scripts resolve subsequently.

Because of this, you can send your entire static page shell, including the navigation, hero, sidebar, and even the footer, in the very first byte flush before any slow backend stream script begins. You then use modern CSS (Flexbox order or CSS Grid grid-template-areas) to visually position the footer at the bottom of the layout.

The browser paints the complete page container and footer immediately, with placeholder skeletons in between, while background streams fill in the gaps without any layout jump or client-side JavaScript.

Ensure that visual reordering maintains an intuitive reading and focus order according to the W3C CSS Grid Placement specification.

Option 1: Multi-Slot Grid with In-Place Skeleton Swapping (Recommended)

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 { escape } from '@/lib/server.ts';
      export default async function (request, context, { signal }) {
        const stats = await fetchMetrics({ signal });
        return `<article class="card result-1"><h3>${escape(stats.uptime)}</h3></article>`;
      }
    </script>
  </div>

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

  <div class="cell-3">
    <script data-bascik-stream>
      import { escape } from '@/lib/server.ts';
      export default async function (request, context, { signal }) {
        const db = await fetchDb({ signal });
        return `<article class="card result-3"><h3>${escape(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 { escape } from '@/lib/server.ts';

      export default async function (request, context, { signal }) {
        const res = await fetch('https://api.example.com/metrics', { signal });
        const stats = await res.json();
        return `<div class="result"><p>${escape(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 { escape } from '@/lib/server.ts';

      export default async function (request, context, { signal }) {
        const res = await fetch('https://api.example.com/billing', { signal });
        const bills = await res.json();
        return `<div class="result"><p>${escape(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 { escape } from '@/lib/server.ts';

      export default async function (request, context, { signal }) {
        const res = await fetch('https://api.example.com/revenue', { signal });
        const data = await res.json();
        return `<div class="result"><h2>${escape(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 arrival order with no placeholders -->
  <script data-bascik-stream>
    export default async function (request, context, { signal }) {
      const item = await fetchPrimaryFeed({ signal });
      return `<article class="feed-card"><h3>${escape(item.title)}</h3></article>`;
    }
  </script>
  <script data-bascik-stream>
    export default async function (request, context, { signal }) {
      const item = await fetchSecondaryFeed({ signal });
      return `<article class="feed-card"><h3>${escape(item.title)}</h3></article>`;
    }
  </script>
</div>

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.

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

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