htmx 4.0: The Fetchening - A Developer Guide to What Is Actually Changing
So, htmx is getting a major version bump. I know what you’re thinking: “Great, another breaking change to deal with.” But hear me out, this one’s I think might actually worth paying attention to.
Wait, didn’t they say there would never be a version 3?
Yeah, Carson Gross (the creator) totally said that. And technically, he didn’t lie, he just skipped straight to version 4. Liked the move.
The real story? After building fixi.js (a super minimal htmx-like library), Carson realized that maybe, just maybe, it was time to ditch XMLHttpRequest (yes, that crusty IE-era API we’ve all been dragging around) and embrace fetch(). And if they’re going to make that breaking change anyway, might as well fix some of the other annoying quirks that have piled up over five years.
Fair enough, honestly.
The Three Things That’ll Actually Affect Your Code
1. XMLHttpRequest is Finally Dead (RIP)
They’re swapping out XMLHttpRequest for fetch(). Mostly this is an internal thing (kinda transparent), but the event model changes because, well, fetch() works differently. You’ll need to update your event listeners (details below).
The upside? Native streaming support, cleaner async code, and we can finally stop pretending it’s 2010.
2. Attribute Inheritance: Now With More Typing
Okay, this one’s gonna hurt a bit. You know how attributes like hx-target would just magically inherit from parent elements? Like CSS, but for htmx? Yeah, that’s gone.
In 4.0, if you want inheritance, you gotta ask for it explicitly with the :inherited modifier.
What this looks like in practice:
Before (htmx 2.x): the magic version:
<div hx-target="#output">
<button hx-post="/up">Like</button>
<button hx-post="/down">Dislike</button>
</div>
<output id="output">Pick a button...</output>
After (htmx 4.0): the explicit version:
<div hx-target:inherited="#output">
<button hx-post="/up">Like</button>
<button hx-post="/down">Dislike</button>
</div>
<output id="output">Pick a button...</output>
See that :inherited tacked onto hx-target? That’s now required. Without it, those buttons have no idea where to put their responses.
Real talk: This is going to be most of your migration work. Time to grep your codebase for hx-target, hx-swap, hx-confirm, and any other attributes you’re using on parent elements. Add :inherited to each one where you’re relying on that implicit behavior.
Yeah, it’s tedious. But honestly? Explicit is probably better than the CSS-style “wait, where is this value even coming from?” confusion.
3. History: No More DOM Snapshots (Good Riddance)
Remember how htmx 2.0 would snapshot your entire DOM and stash it in session storage for fast history navigation? And remember how that would break in weird ways because of third-party scripts, React components, or just… state?
Yeah, they’re done with that.
In 4.0, when you hit the back button, htmx just makes a network request for the content. Simple. Predictable. Works every time.
- Before (htmx 2.x): Snapshots DOM → stores locally → prays nothing breaks
- After (htmx 4.0): Just fetches the page again like a normal browser
If you actually liked the caching behavior (no judgment), there’ll be an extension for that. But for 99% of us, this is going to make history “just work” without weird edge cases. (I hope.)
The New Stuff
Streaming is Built-in Now
Remember trying to hack together progressive loading with multiple requests? Or setting up Server-Sent Events? With fetch(), htmx can now handle streaming responses natively:
<div hx-get="/stream-data" hx-trigger="load">
Loading...
</div>
Your backend sends chunks of HTML as they’re ready, and htmx swaps them in as they arrive. Perfect for:
- Real-time dashboards
- Progress bars that actually show progress
- Loading feed items as they’re fetched
- Any “partial results” scenario
Server-Sent Events Are Back
SSE got pulled out into an extension in 2.0, but now it’s back in core (because it’s basically just streaming):
<div hx-sse="/notifications" hx-swap="beforeend">
Waiting for updates...
</div>
Idiomorph: Smart DOM Diffing in Core
You know how innerHTML just nukes everything and starts over, losing focus, scroll position, and any JS state? Idiomorph is the smart alternative that actually diffs the DOM and only changes what needs changing.
And now it’s built-in:
<div hx-get="/update-content" hx-swap="morphInner">
<input type="text" value="This input won't lose focus on swap">
<p>Other content that'll update smoothly</p>
</div>
Use morphInner or morphOuter and watch your DOM transitions get buttery smooth. This is the same algorithm Hotwire uses, and it’s legit good.
Partials: A Better Way to Handle Complex Swaps
Out-of-band swaps in htmx 2.x got… complicated. The syntax got baroque, and it was trying to do too many things at once.
Enter <partial> elements:
Before (htmx 2.x): doing multiple swaps:
<!-- Your main response -->
<div id="main">Main content here</div>
<!-- Side effects with cryptic syntax -->
<div id="notification" hx-swap-oob="beforeend:#notifications">
New notification
</div>
<div id="counter" hx-swap-oob="innerHTML:#cart-count">
5
</div>
After (htmx 4.0): clean and obvious:
<!-- Your main response -->
<div id="main">Main content here</div>
<!-- Clear, explicit partials -->
<partial hx-target="#notifications" hx-swap="beforeend">
<div>New notification</div>
</partial>
<partial hx-target="#cart-count" hx-swap="innerHTML">
5
</partial>
Partials use regular htmx attributes you already know. No special syntax to remember. It’s just… clearer.
Out-of-band swaps still exist, but they’re going back to their original simple purpose: swapping an element by ID. That’s it.
View Transitions Got Smarter
If you’re using the View Transitions API, htmx 4.0 now queues them properly so they don’t overlap and create visual glitches:
<button hx-get="/next-page" hx-transition="true">
Next Page
</button>
Each transition completes before the next one starts. No more awkward cancellations mid-animation.
Events Make Sense Now
The event naming got a standardization pass. New format is:
htmx:<phase>:<scope>[:<optional-detail>]
So instead of random names, you get predictable ones:
// Before (htmx 2.x)
document.body.addEventListener('htmx:beforeRequest', (e) => {
console.log('Request starting...');
});
document.body.addEventListener('htmx:afterSwap', (e) => {
console.log('Content swapped');
});
// After (htmx 4.0)
document.body.addEventListener('htmx:before:request', (e) => {
console.log('Request starting...');
});
document.body.addEventListener('htmx:after:swap', (e) => {
console.log('Content swapped');
});
It’s more verbose, yes. But when you’re debugging at 2am, you’ll appreciate the consistency.
hx-on Gets Async Superpowers
The hx-on attribute (for inline event handlers) now has a cleaner syntax and actually useful async helpers:
<button hx-post="/action"
hx-on:htmx:after:swap="await timeout('3s'); ctx.newContent[0].remove()">
Click me, wait 3 seconds, then I vanish
</button>
You get:
- Standardized hx-on:
syntax - Built-in async helpers like timeout()
- Access to a context object with useful stuff
Still not as powerful as Alpine.js, but way better for quick inline scripting.
Your Migration Checklist
Alright, let’s talk about actually doing this upgrade:
1. Hunt Down Inherited Attributes
This is the big one. Search your entire codebase for:
# Common attributes that need :inherited
git grep -n "hx-target"
git grep -n "hx-swap"
git grep -n "hx-confirm"
git grep -n "hx-headers"
For each match, ask: “Is this being used by child elements?” If yes, add :inherited:
<!-- Add :inherited to these -->
hx-target="#result" → hx-target:inherited="#result"
hx-swap="outerHTML" → hx-swap:inherited="outerHTML"
hx-confirm="Are you sure?" → hx-confirm:inherited="Are you sure?"
Pro tip: Start with your layout/wrapper components. That’s where most inherited attributes live.
2. Fix Your Event Listeners
Update event names to the new format:
// Old → New
'htmx:beforeRequest' → 'htmx:before:request'
'htmx:afterRequest' → 'htmx:after:request'
'htmx:beforeSwap' → 'htmx:before:swap'
'htmx:afterSwap' → 'htmx:after:swap'
'htmx:configRequest' → 'htmx:config:request'
Good news: Your IDE’s find-and-replace can handle most of this.
3. Test Your History Behavior
The new history approach should work better for most people, but test it:
- Navigate through your app
- Hit the back button a bunch
- Check if everything loads correctly
If you run into issues (unlikely), you can try the history caching extension when it’s available.
4. Simplify Your OOB Swaps
If you have complex out-of-band swap logic, convert it to partials:
<!-- Old complex OOB -->
<div hx-swap-oob="beforeend:#list,afterbegin:#count">...</div>
<!-- New clear partials -->
<partial hx-target="#list" hx-swap="beforeend">
<div>List item</div>
</partial>
<partial hx-target="#count" hx-swap="afterbegin">
<span>1</span>
</partial>
5. Standardize hx-on Syntax
If you’re using hx-on, update to the new format:
<!-- Old -->
<button hx-on="htmx:afterSwap: handleSwap()">
<!-- New -->
<button hx-on:htmx:after:swap="handleSwap()">
6. Consider Streaming Endpoints
This is optional, but if you have slow-loading pages, now’s a good time to add streaming:
Flask example:
from flask import Response
import time
def stream_content():
yield "<div>First chunk</div>"
# Do some work...
yield "<div>Second chunk</div>"
# More work...
yield "<div>Final chunk</div>"
@app.route('/stream')
def streaming_route():
return Response(stream_content(), mimetype='text/html')
Django example:
from django.http import StreamingHttpResponse
from django.views.decorators.http import require_http_methods
import time
@require_http_methods(["GET"])
def stream_search_results(request):
def generate_results():
query = request.GET.get('q', '')
yield '<div class="results">'
# Fetch and iterate over database results
# Note: Evaluate queryset before streaming to avoid DB connection issues
items = list(Product.objects.filter(name__icontains=query)[:20])
for item in items:
yield f'<div class="result-item">{item.name} - ${item.price}</div>'
time.sleep(0.1) # Simulate processing delay
# You could add external API results here
yield '<div class="separator">Related Products:</div>'
related = list(Product.objects.filter(category=items[0].category)[:5]) if items else []
for item in related:
yield f'<div class="result-item related">{item.name}</div>'
yield '</div>'
return StreamingHttpResponse(generate_results(), content_type='text/html')
# urls.py
from django.urls import path
urlpatterns = [
path('search/stream/', stream_search_results, name='stream_search'),
]
Pair with hx-get=“/stream” and boom—progressive loading.
Timeline
Here’s when things are happening:
- Right now: Alpha releases (htmx@4.0.0-alpha1)
- Mid-2026: Beta release (feature complete, just bug fixes)
- Early 2027: Stable 4.0 release
htmx 2.x stays on the latest npm tag until 4.0 is stable, and they’ll keep it updated with security fixes. No pressure to upgrade immediately.
Should You Actually Upgrade?
Upgrade now (alpha/beta) if:
- You’re starting a fresh project
- You want to experiment with streaming
- You’re the type who likes living on the edge
- You’ve got good test coverage
Upgrade when stable if:
- Your current app works fine
- You’ve got time for a small migration project
- You want the new features
Stay on 2.x if:
- It ain’t broke, so why fix it?
- You’re in feature-freeze/maintenance mode
- The :inherited migration seems like too much work right now
No wrong answer here. htmx 2.x isn’t going anywhere.
The Bottom Line
Look, this is a breaking change. There’s no way around that. The :inherited thing is going to be tedious to fix.
But here’s the thing: these changes make sense. Explicit inheritance is clearer. fetch() is the future. Simplified history actually works. And the new features: streaming, morphing, partials are legitimately useful.
Plus, Carson and the team are being transparent about the tradeoffs. They’re giving us tons of time to migrate. And most importantly, the core philosophy of htmx isn’t changing: HTML over the wire, progressive enhancement, locality of behavior.
If you’ve been hitting limitations in htmx 2.x, version 4.0 is going to feel like a breath of fresh air. If you haven’t, well, you’ll probably be fine staying put for now.
Either way, it’s cool to see a library actually modernizing its internals instead of just piling on features.
The four branch is on GitHub if you want to poke around. Alpha releases are up on npm. Give it a shot and see what you think.