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

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:

PingPPoPonPong

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.multipart sets global defaults from JavaScript.

    htmx.config.multipart.reconnectDelay = '1s' htmx.config.multipart.reconnectMaxAttempts = 5
  • hx-config overrides 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:

  • Second swaps now.
  • One second later, First swaps.

With multipart/mixed:

  • Nothing swaps for one second.
  • Then First and Second swap 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:

HeaderEffect
HX-TargetSet the target
HX-RetargetSet the target and override HX-Target
HX-SwapSet the swap
HX-ReswapSet the swap and override HX-Swap
HX-SelectSelect content
HX-ReselectSelect content and override HX-Select
HX-TriggerFire events
HX-LocationIssue a follow-up htmx GET
HX-RedirectNavigate with a full page load
HX-RefreshReload the page when set to true
HX-Push-UrlPush a browser history entry
HX-Replace-UrlReplace the current browser history URL
HX-PushDeprecated; 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-Location skips its part’s swap. The connection stays open.
  • HX-Refresh and HX-Redirect replace 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.cancelled to true

Event detail includes:

  • ctx: the htmx request context
  • part: the BodyPart
  • cancelled: set to true to cancel without preventDefault()
  • 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 URL
  • error: the error value
  • status: 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 --...--
ExampleMeaning
HTTP/1.1 200 OKStatus for the whole response
boundary=...Names the boundary
--...Starts the next part
Content-Type and HX-TargetHeaders for that part
Empty lineStarts the part body
First and SecondPart 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 headerhtmx reads
Content-Length: 5The next five bytes
No Content-LengthUntil 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.