µJS vs Turbo vs htmx

Overview #

µ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.

Feature comparison #

µ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
1 µJS: mu:init, mu:before-fetch, mu:before-render, mu:after-render, mu:fetch-error
2 Turbo: turbo:click, turbo:before-visit, turbo:visit, turbo:submit-start, turbo:submit-end, turbo:before-cache, turbo:before-render, turbo:render, turbo:load, turbo:before-stream-render, turbo:before-fetch-request, turbo:before-fetch-response, turbo:fetch-request-error, turbo:frame-load, turbo:frame-render
3 htmx: htmx:afterOnLoad, htmx:afterProcessNode, htmx:afterRequest, htmx:afterSettle, htmx:afterSwap, htmx:beforeOnLoad, htmx:beforeProcessNode, htmx:beforeRequest, htmx:beforeSend, htmx:beforeSwap, htmx:configRequest, htmx:confirm, htmx:historyCacheError, htmx:historyCacheMiss, htmx:historyCacheMissLoad, htmx:historyRestore, htmx:beforeHistorySave, htmx:load, htmx:prompt, htmx:responseError, htmx:sendError, htmx:sseError, htmx:swapError, htmx:targetError, htmx:timeout, htmx:validation:validate, htmx:validation:failed, htmx:validation:halted, htmx:xhr:abort, htmx:xhr:loadend, htmx:xhr:loadstart, htmx:xhr:progress
4 Datastar: datastar-fetch:started, datastar-fetch:finished, datastar-fetch:error, datastar-fetch:retrying, datastar-fetch:retries-failed, datastar-signal-patch
5 Unpoly: up:click, up:form:submit, up:form:validate, up:fragment:aborted, up:fragment:destroyed, up:fragment:hungry, up:fragment:inserted, up:fragment:keep, up:fragment:kept, up:fragment:loaded, up:fragment:offline, up:fragment:poll, up:framework:boot, up:framework:booted, up:framework:reset, up:layer:accept, up:layer:accepted, up:layer:dismiss, up:layer:dismissed, up:layer:location:changed, up:layer:open, up:layer:opened, up:link:follow, up:link:preload, up:location:changed, up:location:restore, up:motion:finish, up:network:late, up:network:recover, up:request:abort, up:request:aborted, up:request:load, up:request:loaded, up:request:offline, up:assets:changed, up:deferred:load

Patch mode vs Turbo Streams #

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:

µJS — Patch mode

<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 — Turbo Streams

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

Server-Sent Events: µJS vs htmx #

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:

µJS

<!-- 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>

htmx

<!-- 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.

Standard HTTP vs SSE-only: µJS vs Datastar #

µ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.

µJS

<!-- 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.

Datastar

<!-- 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.

µJS vs Unpoly #

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.

µJS — opt-out

<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>

Unpoly — opt-in

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

When to choose #

µ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

Turbo

  • Rails / Hotwire ecosystem
  • Need Turbo Native for iOS/Android
  • Server-sent Turbo Streams
  • Full-stack framework integration
  • Complex real-time features

htmx

  • Triggers on any DOM event
  • WebSocket and SSE support
  • Maximum flexibility
  • Rich extension ecosystem
  • Any element can make requests