Speed up FormData multipart serialization (#31379)
### What / why
`bench/snippets/form-data.mjs` regressed vs the Zig implementation: `new
Response(formData).formData()` −52%, `.text()` −28% (while
`FormData.append` itself got faster), i.e. the multipart
**serialization** path is what slowed down.
The port of `Blob::from_dom_form_data` (the FormData → multipart body
serializer) did a heap allocation + copy for every piece it joined, and
went through `core::fmt` for the boundary and content-type strings:
- every field **name / value / filename** was copied into the
`StringJoiner` (`ZigStringSlice::into_vec()` copies when the slice is
borrowed; the Zig code borrowed ASCII strings and only allocated for
UTF-16/non-ASCII)
- the file part **content type** and the **in-memory blob bytes** were
`push_cloned` (Zig borrowed both — for blob parts this is an extra
O(size) copy)
- the **boundary** was formatted with 16 separate `write!("{:02x}")`
calls through `core::fmt`
- the `multipart/form-data; boundary=…` string was grown from an empty
`Vec` through `core::fmt`/`bstr`
### Fix
`src/runtime/webcore/Blob.rs`:
- hex-encode the boundary in one pass with
`bun_core::fmt::bytes_to_hex_lower`
- `FormDataContext::push_string_slice`: borrow name/value/filename bytes
into the joiner when they're already UTF-8 (the `DOMFormData` outlives
`joiner.done()`, same contract the Zig code used), transfer ownership
when a UTF-16/Latin-1→UTF-8 conversion already allocated, and only copy
when `"`/CR/LF escaping is actually needed (`escape_form_data_name` now
returns `Option`)
- borrow file-part content types and in-memory blob bytes
(`push_static`) instead of cloning
- build the content-type string with one exact-size allocation
- reserve the joiner's node list up front (`StringJoiner::reserve`, new
small helper in `bun_core`)
Serialized output is **byte-for-byte unchanged** (verified against
current main and against bun v1.3.14; the only intentional difference vs
1.3.14 remains the already-shipped WHATWG `%22`/`%0D`/`%0A` name
escaping).
### Benchmarks
`bench/snippets/form-data.mjs`, x64 Linux (Xeon 8375C), comparing the
official **bun v1.3.14 release (last Zig build)**, current main, and
this PR (both local release builds):
| benchmark | Zig 1.3.14 | main | this PR |
|---|---|---|---|
| `new Response(formData).text()` (serialize) | 1.32 µs | 1.77 µs |
**1.10 µs** |
| `new Response(formData).formData()` (round-trip) | 2.45 µs | 3.43 µs |
**2.74 µs** |
| `response.formData()` (parse only) | 1.93 µs | 2.23 µs | 2.21 µs
(unchanged) |
| `FormData.append` | 0.58 µs | 0.68 µs | 0.68 µs (unchanged) |
Serialization is now ~17% faster than the Zig implementation (was 34%
slower) and ~38% faster than main. The round-trip improves ~20% but is
still ~11% behind Zig: the remainder is a fixed cost in the
`.formData()` **parse/consume** path (it reproduces with URL-encoded
bodies too, so it isn't the multipart parser; per-field parse cost is
within ~6% of Zig). That pre-existing parse-side gap (the −12%
`response.formData()` line in the original report) isn't addressed here.
### Tests
`test/js/web/html/FormData-multipart-serialization.test.ts` (new,
focused file next to the existing `FormData-file-error-leak.test.ts`):
- exact wire format: boundary shape, part layout/CRLFs,
`%22`/`%0D`/`%0A` escaping in names & filenames,
`application/octet-stream` fallback, blob/File content types
- full serialize → parse round-trip for string fields, duplicate names,
non-ASCII names/values/filenames, blobs and typed Files
- 64 KB binary blob round-trips byte-for-byte (exercises the borrowed
blob-bytes path; under the ASAN debug build this would catch any
lifetime mistake in the new borrow-based joining)
- a peak-memory check (Linux, child process, VmHWM) that serializing a
128 MB in-memory blob stays at ~1x the blob size — it fails at the ~2x
that the extra per-entry copy on main produces, so the no-copy behavior
is locked in and can't silently regress
All three pass under `bun bd test` (debug+ASAN) and the release build.
`test/js/web/html/FormData.test.ts`, `form-data-boundary-crash.test.ts`,
`body.test.ts`, `blob.test.ts` and `FormData-file-error-leak.test.ts`
pass with this change (the leak fixture reports 0 MB growth before and
after — ownership only got narrower).
This is a performance-only change: the new tests document/lock the
serialization format but necessarily pass on main too. R
robobun committed
fc268251f5775acb08777aeba69aa3737c52daef
Parent: a49b308
Committed by GitHub <noreply@github.com>
on 5/25/2026, 8:11:04 PM