µJS
- Classic multi-page websites
- Minimal footprint is a priority
- No build step, drop-in solution
- Live search, polling, and event-driven updates
- Server-Sent Events for real-time features
- You want a simple, focused library
µJS, Turbo (Hotwire), and htmx are all libraries that enhance traditional multi-page websites with AJAX navigation. They intercept links and forms to fetch content without full page reloads. Each takes a different approach to scope, size, and flexibility.
| µJS | Turbo | htmx | Datastar | Unpoly | |
|---|---|---|---|---|---|
| Size (min+gz) | ~5 KB | ~25 KB | ~16 KB | ~11 KB | ~59 KB |
| Dependencies | 0 | @hotwired/turbo | 0 | 0 | 0 |
| Build step | None | Required | None | None | None |
| Fragment replacement | mu-target / mu-source |
Turbo Frames | hx-target / hx-swap |
SSE events + CSS selector | up-target |
| Multi-fragment updates | Patch mode | Turbo Streams | hx-swap-oob |
Multiple SSE events | up-hungry |
| HTTP methods | GET/POST/PUT/PATCH/DELETE | GET/POST | GET/POST/PUT/PATCH/DELETE | GET/POST/PUT/PATCH/DELETE | GET/POST/PUT/PATCH/DELETE |
| DOM morphing | Idiomorph (optional, +3.3 KB) | Built-in (Turbo 8+) | Idiomorph (extension, +3.4 KB) | Built-in (idiomorph port) | None |
| View Transitions | Built-in | Built-in | Built-in (htmx 2+) | Built-in | None |
| Browser history | Auto on links, smart defaults per mode (mu-history) |
Auto on Drive, skip on Frames (data-turbo-action) |
Opt-in per element (hx-push-url / hx-replace-url) |
Limited (Pro) | Auto (up-history) |
| Scroll control | Per-element (mu-scroll), smart defaults per mode |
Auto, no per-link control | Per-element via hx-swap modifiers (scroll:, show:) |
Per-element (Pro) | Per-element (up-scroll) |
| Scroll restoration | Built-in (automatic) | Built-in (automatic) | Unreliable (localStorage snapshot) |
Implicit (morphing) | Built-in (automatic) |
| Forms | Built-in | Built-in | Built-in | Built-in | Built-in |
| Prefetch | Built-in | Built-in | Extension, +1.6 KB | Server-side (Speculation Rules) | Built-in (up-preload) |
| Progress bar | Built-in | Built-in | None | None | Built-in |
| Custom events | 5 events 1 | ~15 events 2 | ~20 events 3 | 6 events 4 | ~40 events 5 |
| Trigger on any event | Yes (mu-trigger) |
No (links/forms only) | Yes (hx-trigger) |
Yes (data-on) |
Partial (forms only) |
| Debounce / Polling | Built-in | No | Built-in (hx-trigger modifiers) |
Built-in | Built-in |
| WebSocket / SSE | SSE (mu-method="sse") |
Turbo Streams | WS (extension, +2 KB) SSE (extension, +1.1 KB) |
SSE (core architecture) | None |
Both µJS and Turbo support updating multiple page fragments in a single server response. The key difference: µJS uses standard HTML with attributes, while Turbo requires custom elements wrapping each fragment in a <template> tag.
Consider a comment form that appends the new comment to a list, resets the form, and updates a counter — all from one POST response:
<div class="comment" id="comment-32"
mu-patch-target="#comments" mu-patch-mode="append">
<p>Great article!</p>
<time>March 3, 2026</time>
</div>
<form id="comment-form" action="/comments" method="post"
mu-patch-target="#comment-form">
<textarea name="body"></textarea>
<button type="submit">Submit</button>
</form>
<span mu-patch-target="#comment-count"
mu-patch-mode="update">
14 comments
</span>
<turbo-stream action="append" target="comments">
<template>
<div class="comment" id="comment-32">
<p>Great article!</p>
<time>March 3, 2026</time>
</div>
</template>
</turbo-stream>
<turbo-stream action="replace" target="comment-form">
<template>
<form id="comment-form" action="/comments"
method="post">
<textarea name="body"></textarea>
<button type="submit">Submit</button>
</form>
</template>
</turbo-stream>
<turbo-stream action="update" target="comment-count">
<template>
14 comments
</template>
</turbo-stream>
With µJS, each fragment is the content — annotated with mu-patch-target and mu-patch-mode. With Turbo Streams, each fragment must be wrapped in a <turbo-stream> / <template> structure. The µJS approach produces less boilerplate and keeps the HTML inspectable as-is.
Moreover, with µJS you can use the exact same HTML in your original page and in the patch response. On initial page load, the mu-patch-target attributes have no effect; they are only used when µJS processes a patch response.
Both libraries support real-time updates via SSE, but the approaches differ significantly. µJS has built-in SSE that reuses the same patch syntax. htmx requires loading a separate extension, and each target element must declare which SSE event it listens to.
Consider a live chat with a message feed, an online counter in the header, and a typing indicator:
<!-- Single trigger, anywhere in the page -->
<div mu-trigger="load" mu-url="/chat/stream"
mu-method="sse" mu-mode="patch"></div>
<!-- Targets can be anywhere -->
<header>
<span id="online">0 online</span>
</header>
<main>
<div id="messages"></div>
<p id="typing"></p>
</main>
Server sends standard SSE with HTML fragments:
data: <div mu-patch-target="#messages"
mu-patch-mode="append">
<p><b>Alice:</b> Hello!</p></div>
data: <span mu-patch-target="#online">
3 online</span>
data: <p mu-patch-target="#typing">
Bob is typing...</p>
<!-- Requires the SSE extension -->
<script src="htmx-ext/sse.js"></script>
<!-- All targets must be inside the
sse-connect container -->
<div hx-ext="sse"
sse-connect="/chat/stream">
<span id="online"
sse-swap="online">0 online</span>
<div id="messages"
sse-swap="messages"
hx-swap="beforeend"></div>
<p id="typing"
sse-swap="typing"></p>
</div>
Server must use named events matching each target:
event: messages
data: <p><b>Alice:</b> Hello!</p>
event: online
data: 3 online
event: typing
data: Bob is typing...
With µJS, SSE is built-in and uses the same patch syntax as forms and links — there is nothing new to learn. The trigger element and its targets are fully decoupled: they can live anywhere in the page. The server decides what to update, and adding a new target only requires a server-side change.
With htmx, SSE requires a separate extension with its own API (sse-connect, sse-swap). All target elements must be nested inside the SSE container. Each target must declare which named event it listens to, creating a tight coupling between client markup and server event types — adding a new target means changing both sides.
µJS uses standard HTTP: a click triggers a fetch(), the server returns HTML, µJS injects it.
Datastar takes a fundamentally different approach: all server communication goes through Server-Sent Events, even a simple fragment load.
<!-- Standard link -->
<a href="/comments"
mu-target="#comments"
mu-source="#comments">
Load comments
</a>
<div id="comments">Loading...</div>
Server returns plain HTML. Any backend works.
<!-- Requires an action expression -->
<button
data-on-click="@get('/comments')">
Load comments
</button>
<div id="comments">Loading...</div>
Server must return an SSE stream (text/event-stream) with Datastar-specific events. A server-side SDK is recommended.
With µJS, any backend that returns HTML works out of the box: no SDK, no special protocol. Datastar's SSE-first architecture is powerful for real-time streaming, but it means every endpoint must speak SSE, even for simple one-shot requests. This adds complexity to the server setup and makes Datastar harder to retrofit onto existing websites.
Additionally, some Datastar features (browser history, scroll control, view transition names) are reserved for Datastar Pro (commercial license). µJS includes all features in a single open-source package.
Unpoly is a mature framework (10+ years), but at ~59 KB it is 12× the size of µJS (~5 KB), and yet it lacks several features that µJS provides: no DOM morphing (focus, form values, and scroll position are lost on updates), no View Transitions API, no SSE or WebSocket support, and no generic event triggers (only form-specific auto-submit).
The most significant design difference is opt-in vs opt-out. µJS intercepts all internal links and forms automatically after mu.init(). Unpoly requires explicitly adding up-follow to each link and up-submit to each form.
<script src="mu.min.js"></script>
<script>mu.init();</script>
<!-- Works automatically -->
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<form action="/search" method="get">
<input type="text" name="q">
<button type="submit">Search</button>
</form>
<!-- Opt out when needed -->
<a href="/file.pdf" mu-disabled>Download</a>
<script src="unpoly.min.js"></script>
<link href="unpoly.min.css" rel="stylesheet">
<!-- Each element must opt in -->
<nav>
<a href="/" up-follow>Home</a>
<a href="/about" up-follow>About</a>
</nav>
<form action="/search" method="get" up-submit>
<input type="text" name="q">
<button type="submit">Search</button>
</form>
<!-- No attribute = no interception -->
<a href="/file.pdf">Download</a>
With µJS, making an existing website dynamic takes two lines of code: add the script and call mu.init(). With Unpoly, every link and form in every template must be individually annotated.