The hx-multipart extension lets one multipart/mixed HTTP response stream multiple parts.
Installing
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta6/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/gh/bigskysoftware/htmx@four/src/ext/hx-multipart.min.js"></script>
Usage
Update an Element
Start with a typical htmx request using hx-get:
<button hx-get="/ping"> Ping </button>
Instead of text/html, respond with multipart/mixed:
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... --... Content-Type: text/html Pong --...--
Backend libraries
- Python:
scriptogre/multipart-responsefor Django, FastAPI, FastHTML, and Starlette
Example:
from fastapi import FastAPI from multipart_response.fastapi import MultipartResponse, Part app = FastAPI() @app.get("/ping", response_class=MultipartResponse) async def ping(): yield Part("Pong", media_type="text/html")
More backends are planned.
The part replaces the button’s content:
<button hx-get="/ping"> Pong </button>
htmx uses the same rules as with a text/html response:
Stream an Update
The server can stream HTML using multiple parts:
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... --... Content-Type: text/html P --... Content-Type: text/html Po --... Content-Type: text/html Pon --... Content-Type: text/html Pong --...--
The button changes as each part arrives:
Ping → P → Po → Pon → Pong
Choose the Swap
Use hx-swap and hx-target to choose how and where updates swap:
<button hx-post="/generate" hx-target="next output" hx-swap="beforeend"> Generate </button> <!-- LLM tokens stream here --> <output></output>
The server streams three parts:
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... --... Content-Type: text/html Hello --... Content-Type: text/html , world --... Content-Type: text/html ! --...--
hx-swap="beforeend" accumulates them in <output>:
<output>Hello, world!</output>
Every part inherits the request’s hx-target, hx-swap, and hx-select.
Update Elements
Use a normal htmx request to update several elements:
<button hx-get="/events">Connect</button> <div id="feed"></div> <div id="status">Offline</div>
Part headers choose where each body swaps:
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... --... Content-Type: text/html HX-Target: #status Online --... Content-Type: text/html HX-Target: #feed <p>New</p> --...--
The page becomes:
<button hx-get="/events">Connect</button> <div id="feed"> <p>New</p> </div> <div id="status">Online</div>
Like hx-swap-oob and <hx-partial>, part headers let one response update multiple elements.
Persistent Connections
Persistent connections reconnect after a response ends.
Open Connections
Use hx-multipart:connect for a persistent GET connection:
<div hx-multipart:connect="/events"></div> <div id="status">Offline</div>
The server sends a part:
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... --... Content-Type: text/html HX-Target: #status Online --...--
The page becomes:
<div hx-multipart:connect="/events"></div> <div id="status">Online</div>
The server may hold the response open and send more parts. If it ends, hx-multipart:connect reconnects.
Connections open on load. Use hx-trigger to connect later:
<button id="connect">Connect</button> <div hx-multipart:connect="/events" hx-trigger="click from:#connect"> </div>
All hx-trigger modifiers are supported.
Close Connections
Close a connection when a part fires a named event:
<div hx-multipart:connect="/progress" hx-multipart:close="done" hx-target="#status"></div> <div id="status">Working</div>
The server sends HX-Trigger with the final part:
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... --... Content-Type: text/html HX-Trigger: done Complete --...--
The part still swaps, then the connection stops:
<div id="status">Complete</div>
Resume Connections
Identify each part so a reconnect can start after the last successful update:
--... Content-Type: text/html HX-Part-ID: event-42 <p>New message</p>
After the part’s actions and swap finish, the next reconnect includes:
HX-Last-Part-ID: event-42
The server should return parts after that ID. The client processes every returned part without deduplicating IDs.
Reset the cursor with an empty header:
HX-Part-ID:
A missing HX-Part-ID leaves the cursor unchanged. A failed or cancelled part does not update it.
Configure Connections
You can configure hx-multipart in three places:
-
<meta name="htmx-config">sets global defaults from HTML.<meta name="htmx-config" content="multipart.reconnectDelay:1s multipart.reconnectMaxAttempts:5"> -
htmx.config.multipartsets global defaults from JavaScript.htmx.config.multipart.reconnectDelay = '1s' htmx.config.multipart.reconnectMaxAttempts = 5 -
hx-configoverrides the defaults for one connection.<div hx-multipart:connect="/events" hx-config="multipart.reconnectMaxAttempts:2"> </div>
These values are read when multipart handling begins.
Overlap Swaps
Use multipart/parallel so a swap or settle delay does not block later parts:
Content-Type: multipart/parallel; boundary=... --... HX-Target: #one HX-Swap: innerHTML swap:1s First --... HX-Target: #two Second --...--
With multipart/parallel:
Secondswaps now.- One second later,
Firstswaps.
With multipart/mixed:
- Nothing swaps for one second.
- Then
FirstandSecondswap in order.
Use multipart/parallel only when either swap can finish first.
Mix Content Types
With multipart/mixed, each part can use any media type in its Content-Type, including:
- JSON:
application/json - Audio:
audio/mpeg - Video:
video/mp4 - Generic binary:
application/octet-stream - Custom:
application/vnd.example.binary
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... --... Content-Type: text/html <p>Done</p> --... Content-Type: application/json {"sentiment":"positive","confidence":0.94} --... Content-Type: audio/mpeg <audio bytes> --...--
One HTTP response can swap LLM-generated HTML, return structured data, and stream raw audio.
Handle Parts Yourself
Take over a content type with htmx:multipart:before:part:
document.addEventListener('htmx:multipart:before:part', event => { let { part } = event.detail if (part.headers.get('Content-Type') !== 'application/json') return event.preventDefault() event.detail.waitUntil(part.json().then(update)) })
preventDefault() skips the built-in swap. waitUntil() waits for asynchronous handling.
Read the body as:
- JSON:
part.json() - Blob:
part.blob() - Bytes:
part.bytes() - Stream:
part.body
Attributes
hx-get/hx-post/hx-put/…
Any htmx request can process a multipart response. Normal requests stop after one response.
<button hx-post="/generate" hx-target="#result"> Generate </button>
Each part inherits the request’s swap settings unless its own headers override them.
hx-multipart:connect
Opens a persistent GET request:
<div hx-multipart:connect="/events"></div>
Defaults:
Removing the element closes its connection.
hx-multipart:close
Closes a connection after a part fires the named HX-Trigger event:
<div hx-multipart:connect="/events" hx-multipart:close="done"></div>
Headers
HX-Part-ID
Identifies a response part for reconnecting:
HX-Part-ID: event-42
The extension records the value after the part’s actions and swap finish. A missing header leaves the current ID unchanged. An empty value resets it.
With multipart/parallel, the recorded ID advances only after that part and every earlier accepted part finishes successfully.
HX-Last-Part-ID
Identifies the last completed part in a reconnect request:
HX-Last-Part-ID: event-42
The server decides how to resume after that ID. The header is omitted after HX-Part-ID resets the cursor.
HX-* Headers
The envelope is a normal HTTP response. It may include ordinary HTTP headers and any htmx response header.
Envelope swap headers set defaults for every part:
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... HX-Target: #feed HX-Swap: beforeend HX-Select: .item --... Content-Type: text/html <div class="item">First</div> --... Content-Type: text/html <div class="item">Second</div> --...--
Both parts select .item and append it to #feed.
Part headers override the envelope defaults:
--... Content-Type: text/html HX-Retarget: #status HX-Reswap: innerHTML HX-Reselect: .message HX-Trigger: ready <p class="message">Ready</p> --...--
This part selects .message, replaces #status, and fires ready.
Multipart envelopes and parts support these htmx response headers:
| Header | Effect |
|---|---|
HX-Target | Set the target |
HX-Retarget | Set the target and override HX-Target |
HX-Swap | Set the swap |
HX-Reswap | Set the swap and override HX-Swap |
HX-Select | Select content |
HX-Reselect | Select content and override HX-Select |
HX-Trigger | Fire events |
HX-Location | Issue a follow-up htmx GET |
HX-Redirect | Navigate with a full page load |
HX-Refresh | Reload the page when set to true |
HX-Push-Url | Push a browser history entry |
HX-Replace-Url | Replace the current browser history URL |
HX-Push | Deprecated; use HX-Push-Url |
Swap settings use this priority, from highest to lowest:
TARGET part HX-Retarget --> part HX-Target --> envelope HX-Retarget --> envelope HX-Target --> hx-target SWAP part HX-Reswap --> part HX-Swap --> envelope HX-Reswap --> envelope HX-Swap --> hx-swap SELECT part HX-Reselect --> part HX-Select --> envelope HX-Reselect --> envelope HX-Select --> hx-select
- Initial envelope swap headers remain the defaults after reconnecting.
- Envelope actions wait until multipart handling ends. Part actions run as each part arrives.
HX-Locationskips its part’s swap. The connection stays open.HX-RefreshandHX-Redirectreplace the page, which closes the connection.
Accept
Advertises multipart support on every htmx request while the extension is loaded:
Accept: text/html, multipart/mixed, multipart/parallel
Existing values are preserved.
Content-Type
A multipart response must include its media type and boundary:
Content-Type: multipart/mixed; boundary=... Content-Type: multipart/parallel; boundary=...
Part bodies always arrive in wire order. When the first swap is slow, the handling order differs:
TIME ------------------------------------------------------------> multipart/mixed Part 1 [read][actions][--------- swap ---------][finished] Part 2 [read][actions][swap][finished] multipart/parallel Part 1 [read][actions][--------- swap ---------][finished] Part 2 [read][actions][swap][finished]
multipart/mixed waits for each part’s swap to finish before reading the next body. multipart/parallel runs each part’s actions in arrival order, starts its swap, then reads the next body without waiting for that swap to finish.
The HX-Part-ID cursor still advances in wire order. A later completed part waits for every earlier accepted part to finish successfully.
When every swap finishes immediately, both formats usually look the same. See Overlap Swaps for a visible comparison.
Use multipart/parallel when:
- Parts update independent targets.
- A later update is useful before an earlier swap finishes.
- A swap delay or CSS settle phase should not block later updates.
Use multipart/mixed when:
- Parts update the same target.
- A part depends on the DOM produced by an earlier part.
- Arrival order must also be completion order.
Native view transitions do not run in parallel. htmx queues transition:true swaps globally, so multipart/parallel can queue the next transition sooner but still runs each transition one at a time.
The extension includes the Response.prototype.parts() parser for both formats.
Events
Event data is available on event.detail.
Connection events expose:
event.detail.connection = { url, config, lastPartId, attempt, status, cancelled }
htmx:multipart:before:connection
Fires before htmx reads the initial response or starts a reconnect.
document.addEventListener('htmx:multipart:before:connection', event => { if (event.detail.connection.attempt > 5) event.preventDefault() })
htmx:multipart:after:connection
Fires when the initial response or a reconnect is ready to stream.
document.addEventListener('htmx:multipart:after:connection', event => { console.log('Connected:', event.detail.connection.url) })
htmx:multipart:before:part
Fires before htmx reads a part body or runs its actions.
Take over the part and use detail.waitUntil() for asynchronous work:
document.addEventListener('htmx:multipart:before:part', event => { let { part, waitUntil } = event.detail event.preventDefault() waitUntil(handle(part)) })
Cancel either way:
- call
event.preventDefault() - set
event.detail.cancelledtotrue
Event detail includes:
ctx: the htmx request contextpart: theBodyPartcancelled: set totrueto cancel withoutpreventDefault()waitUntil(promise): wait for custom processing before this part finishes
htmx:multipart:after:part
Fires after htmx runs a part’s actions and swap. It does not fire when before:part is cancelled.
document.addEventListener('htmx:multipart:after:part', event => { console.log('Handled:', event.detail.part.headers) })
htmx:multipart:close
Fires when a connection closes.
document.addEventListener('htmx:multipart:close', event => { console.log('Closed:', event.detail.reason) })
reason is part, removed, cancelled, or ended.
htmx:multipart:error
Fires after a stream, network, status, or content type error.
document.addEventListener('htmx:multipart:error', event => { console.error('Multipart error:', event.detail.error) })
url: the request URLerror: the error valuestatus: the HTTP status when available
Config
multipart.reconnect
Control whether a completed or failed response reconnects automatically.
<meta name="htmx-config" content="multipart.reconnect:false">
Defaults to true for hx-multipart:connect and false for normal htmx requests.
multipart.reconnectDelay
Set how long to wait before the first reconnect attempt.
<meta name="htmx-config" content="multipart.reconnectDelay:1s">
Defaults to 500 milliseconds. Each failed attempt doubles the delay, and values may be milliseconds or time strings such as 500ms, 1s, and 2m.
multipart.reconnectMaxDelay
Limit how long to wait between reconnect attempts.
<meta name="htmx-config" content="multipart.reconnectMaxDelay:30s">
Defaults to 60000 milliseconds. Use milliseconds or a time string.
multipart.reconnectMaxAttempts
Limit how many consecutive failed reconnects are attempted.
<meta name="htmx-config" content="multipart.reconnectMaxAttempts:5">
Defaults to Infinity.
multipart.reconnectJitter
Spread reconnect attempts so many clients do not retry at once.
<meta name="htmx-config" content="multipart.reconnectJitter:0">
Defaults to 0.3, which randomizes each delay by up to ±30%. Use 0 for exact delays.
multipart.pauseOnBackground
Close the request while the page is hidden and reconnect when it becomes visible.
<meta name="htmx-config" content="multipart.pauseOnBackground:false">
Defaults to true for hx-multipart:connect and false for normal htmx requests. Use HX-Part-ID and server-side replay to recover parts sent while disconnected.
How multipart/mixed Works
A normal HTML response has one body:
HTTP/1.1 200 OK Content-Type: text/html <p>One update</p>
A multipart response puts many header/body pairs inside that body:
HTTP/1.1 200 OK Content-Type: multipart/mixed; boundary=... --... Content-Type: text/html HX-Target: #one First --... Content-Type: text/html HX-Target: #two Second --...--
| Example | Meaning |
|---|---|
HTTP/1.1 200 OK | Status for the whole response |
boundary=... | Names the boundary |
--... | Starts the next part |
Content-Type and HX-Target | Headers for that part |
| Empty line | Starts the part body |
First and Second | Part bodies |
--...-- | Ends the response |
The status line appears once. Parts have no status codes.
How does a part body end?
--... Content-Type: text/plain Content-Length: 5 Hello --...--
| Part header | htmx reads |
|---|---|
Content-Length: 5 | The next five bytes |
No Content-Length | Until the next boundary |
A boundary always follows the body. If the boundary is part, a body line starting with --part will be mistaken for the next part.