A Talk by Stu Kennedy · May 2026

Build on the web.
Not around it.

Why the default web architecture is more complex than it needs to be —
and what happens when you use the platform as designed.

~30kb
Client JS
~50%
Less code
~0
Framework lock-in
Edge
Global compute

"What if the web already has
an application protocol —
and we just stopped reinventing it?"

HTTP is not just a transport layer. It has verbs, status codes, caching, content negotiation, and hypermedia controls. Most modern web apps ignore all of it and rebuild these concepts inside JavaScript.
The Default

JSON API → JS Framework → Pixels

The standard architecture for a modern web app.

Database API Layer Schema + Codegen Typed Client React Hooks Components DOM

6 layers of indirection to turn database rows into something the user can see. Each layer has its own abstractions, error handling, caching, and testing strategy.

The Hidden Cost

What you're actually building.

1

A state management layer

TanStack Query, Redux, Zustand — all exist because JSON doesn't tell the browser how to render itself.

2

A codegen pipeline

OpenAPI → generated types → generated hooks → versioned packages. Breaks when the schema changes.

3

A rendering engine

React, Vue, Svelte — they exist to solve a problem HTML already solves: turning data into pixels.

4

A composition layer

One screen = multiple API calls → client composition → loading states → error boundaries.

5

A cache coordination system

Client caches go stale. Invalidations propagate. Realtime updates trigger re-fetches.

6

Framework upgrades as a lifestyle

React 16→17→18→19. Next.js Pages→App Router→RSC. Weeks of engineering time each time.

The Alternative

HTTP request → HTML fragment

One layer. The server composes the data, renders the HTML, and sends it.

Database Hono Handler HTML Fragment Browser renders

4 steps, zero codegen, zero client state. The browser's native rendering engine does what React does — for free.

The same app. Two architectures.

JSON → Client

  • Schema → codegen → typed client
  • Client builds HTML from JSON
  • Client manages cache & retry
  • Client composes multiple responses
  • React/TanStack framework lock-in
  • ~180kb client JS bundle

HTML over the wire

  • Route → handler → HTML fragment
  • Server builds HTML from data
  • HTTP caching + HTMX invalidation
  • Server composes — always one request
  • Framework-free frontend
  • ~30kb client JS bundle
Architecture

HTML over the wire.
Compute at the edge.

Browser Layer
HTMX
Datastar
Alpine.js
↕ HTTP (HTML fragments) · SSE (realtime)
Edge — Cloudflare Workers
Hono
Auth
JSX
Effect TS
Services
Effect TS
Drizzle
Durable Objects
Data
D1
KV
R2
🔴 LIVE — HTMX Demo

Click the button. Watch the fragment swap.

One button. One GET request. The server returns an HTML fragment. HTMX swaps it in. That's it.

htmx-demo.local
↑ Click the button — the response replaces this area
Network Log
What happened: The button sent a GET request. The server responded with an HTML fragment. HTMX swapped it into #htmx-click-result. The network log above shows every detail — method, URL, target, swap strategy, and the actual HTML response.
🔴 LIVE — HTMX Todo App

Add, toggle, delete. Try it.

Every action sends a request. The server returns updated HTML. The list re-renders. Zero client state.

The HTML for a toggle button:

<button
  hx-put="/api/todos/1/toggle"
  hx-target="#todo-list"
  hx-swap="innerHTML"
></button>

The form:

<form hx-post="/api/todos"
  hx-target="#todo-list">
  <input name="text"/>
  <button>Add</button>
</form>

Working demo — go ahead:

htmx-demo.local/todos
  • Try clicking this button
  • Add a new todo below
  • Toggle one as complete
Network Log — every request/response as it happens
Each action = one HTTP request → one HTML response. Toggle sends PUT, delete sends DELETE, add sends POST. The network log shows exactly what goes over the wire — method, URL, target, swap strategy, and the HTML the server returned.
🔴 LIVE — Datastar SSE Demo

Server-Sent Events. Words appearing live.

The server opens an SSE connection and pushes HTML fragments word by word. The browser applies them as they arrive. This is Datastar's model.

GET /demo/datastar/stream (SSE)
↑ Click to start — watch the server push fragments in real time
SSE Event Log
Each word is a separate SSE event. The server sends event: datastar-merge-fragments with an HTML fragment. Datastar patches the DOM. Watch the network log above — each event appears as it's pushed.
🔴 LIVE — Complex Datastar Dashboard

"Hypermedia is only for simple stuff." Watch this.

dashboard.live — SSE pushing HTML fragments to 6 regions simultaneously
Requests/sec
waiting...
P99 Latency
waiting...
Error Rate
waiting...
Live Activity
SSE connecting...
Throughput (last 10s)
Service Status
API
DB
Cache
Queue
SSE Event Log — 6 regions updating from one stream
One SSE connection. Six independent regions. The server pushes targeted HTML fragments — metrics update, bars animate, feed items appear, status dots flip. Each fragment replaces only its target element. No React. No virtual DOM. No client state. Just HTML fragments over SSE.
🔴 LIVE — 30 FPS DOM Patching

High-frequency UI.
30 updates a second.

telemetry.live — 30fps HTML fragments over SSE
0 FPS Nodes patched: 90/frame
CPU CORE 0
MEMORY PRESSURE
NETWORK I/O
SSE Log (Throttled log display for readability)
No Canvas. No WebGL. No React. This is just HTML fragments being swapped into the DOM 30 times a second using Datastar's idiomorph engine. Hypermedia scales to high-frequency telemetry without dropping frames when you stop rebuilding the DOM in JS and just let the browser render.
Realtime

SSE + Durable Objects.
No WebSocket complexity.

Server-Sent Events give you realtime updates with native browser support. Durable Objects give you stateful coordination at the edge.

Durable Objects — the stateful core

  • Authoritative event log (cursor source)
  • Presence tracking
  • Backpressure / batching
  • Auth verification on reconnect

SSE — the delivery mechanism

  • Native browser support — no libraries
  • Auto-reconnect with Last-Event-ID
  • Server pushes HTML fragment updates
  • Subscription caps at the DO
Cursor replay is free. SSE's native Last-Event-ID header + DO's in-memory event log = cursor resume without building a subscription system from scratch.
Server-Side Power

Effect TS: typed errors,
composable services.

Every handler runs inside an Effect pipeline — tagged error unions, dependency injection, structured concurrency, observability for free.

Typed errors → compiler catches everything:

const renderError = Effect.catchTags({
  NotFoundError: (e) =>
    html(<EmptyState title={...} />),
  ValidationError: (e) =>
    html(<FormErrors fields={...} />),
  RateLimitedError: (e) =>
    html(<RateLimitBanner />),
  ...// missing a tag? Compile error.
});

Service layers — test by swapping:

// Production: D1 database
const Live = UserService.Live

// Test: in-memory fixtures
const Test = UserService.Test

// Same routes, same type signatures
// — different backing services
app.get("/api/users/:id",
  authenticate(),
  async (c) => {
    const program = Effect.gen(function* () {
      const svc = yield* UserService;
      const user = yield* svc.getProfile(id);
      return c.html(<Profile {...user} />);
    }).pipe(renderError);
  }
);
Error Handling

Every error is a rendered response.

In a hypermedia architecture, error handling is just rendering. The server always returns HTML.

404 → Empty state component

📭
No results found
Try adjusting your filters

429 → Rate limit banner

⏳ Slow down — try again in 30 seconds

Validation → Inline field errors

Email
abc
⚠ Invalid email format

Degraded → Partial data warning

⚠ Some data may be stale — refresh for latest
The Numbers

Same requirements. Less complexity.

Requirement JSON-first Hypermedia
Wire formatJSON over RPCHTML + SSE
Client stateTanStack / ReduxDOM is state
InvalidationEvent signals → re-fetchSSE → HTMX auto-refetch
Error handlingTyped JSON → client switchTagged errors → HTML
RealtimeWebSocket + cursor protocolSSE + DO + Last-Event-ID
CodegenRequiredNone
Bundle~180kb~30kb
Framework lock-inNext.js / React / VueNone
Show, Don't Tell

Same feature. Half the code.

JSON-first: ~40 lines, 4 files

// 1. Schema
export const op = {
  input: z.object({ id: z.string() }),
  output: MetricsSummary,
  policy: { roles: ['admin'] },
};
// 2. Codegen'd hook (auto)
function useMetrics() { ... }
// 3. React component
function Panel() {
  const { data, error } = useMetrics();
  if (error) return <Error />;
  return <View data={data} />;
}
// 4. API seam
export async function POST(req) {
  const actor = await resolve(req);
  enforce(actor, schema.policy);
  return backend.metrics(input);
}

Hypermedia: ~20 lines, 2 files

// 1. HTML
<div
  hx-get="/api/metrics"
  hx-trigger="load, refresh from /sse"
  hx-swap="innerHTML"
>Loading...</div>

// 2. Route handler
app.get("/api/metrics",
  authenticate(),
  authorize('admin'),
  async (c) => {
    const data = yield* svc.getMetrics(id);
    return c.html(
      <MetricsPanel {...data} />
    );
  }
);
Zero codegen. Zero client state. The HTML fragment IS the contract. The route IS the type boundary. Hono gives compile-time safety on routes. Drizzle gives compile-time safety on queries.
Honest Trade-offs

Nothing is free. Here are the costs.

Skill shift

React-native teams need to learn a different mental model. The learning curve is short, but it IS a shift.

Rich interactions need JS

Drag-and-drop, rich text, interactive canvases — these need Alpine.js or light JS supplements.

Weaker wire types

No compile-time type safety from schema to rendered output. The trade: zero codegen complexity.

Incremental, not all-in

HTMX works alongside existing JS. Adopt route by route — start with one panel, one form.

The bet is that fewer moving parts outweighs the loss of compile-time wire types. In 25 years of building production systems, that bet has paid off more often than not.

"The web already has
an application protocol.
Use it."

GET HTML Browser User

Stu Kennedy
HTMX Core Contributor · stu@stukennedy.com

Built with conviction. Open to being wrong.