5 min read
I timed nested config merges on this box: defu 6.1.7 finished about 430,000–475,000 merges per second, while lodash.merge 4.6.2 managed about 75,000–77,000. That is roughly 5.5–6× faster, for 452 B gzip instead of 4.1 KB.
Short answer: use defu when you want a tiny defaults helper that leaves your defaults object alone and skips null/undefined overlays. Reach for lodash.merge only if you already ship lodash, or you rely on its index-by-index array merge and null-overwrites-default rules. No affiliate links in this post.
| Build (esbuild browser minify) | Minified | gzip -9 |
|---|---|---|
| defu 6.1.7 | 827 B | 452 B |
| lodash.merge 4.6.2 | 10,647 B | 4,119 B |
About 9× smaller on gzip. If you already carry lodash for other helpers, the merge export is effectively free and this table does not apply. For a single utility, size-limit vs bundlesize will flag the 4 KB. Broader lodash trimming is covered in es-toolkit vs lodash.
| Nested config merge (median of 9) | defu | lodash.merge | Object.assign shallow | spread shallow |
|---|---|---|---|---|
| run 1 (defu first) | 427,847 ops/s | 77,085 ops/s | 9.44M ops/s | 13.4M ops/s |
| run 2 (order reversed) | 475,272 ops/s | 75,387 ops/s | 8.85M ops/s | 11.7M ops/s |
Yes, by about 5.5–6× on this fixture. Shallow assign/spread stay in the millions of ops/s because they do not walk nested keys; they are not a substitute when you need deep defaults. I used a Nuxt-ish config shape (server, build, features, meta) so the walk depth matches what Vite/Nuxt plugins actually merge.
| Case | defu | lodash.merge |
|---|---|---|
| Overlay { tags: ["c"] } on defaults { tags: ["a","b"] } | ["c","a","b"] (concat) | ["c","b"] (index merge) |
| Overlay { a: null } on { a: 1, b: 2 } | { a: 1, b: 2 } (null skipped) | { a: null, b: 2 } |
| Overlay { a: undefined } | defaults kept | defaults kept |
| Nested keep of sibling keys | kept | kept |
| Defaults object after call | unchanged | first arg mutated |
These rules matter more than the speed gap. If your config uses arrays as replaceable lists (plugin lists, tag lists), default defu will grow them. createDefu can replace arrays instead; I verified that custom path returned ["c"] only. For schema-level defaults with clearer types, compare notes with Zod vs Valibot.
| Situation | My pick |
|---|---|
| Nuxt / unjs-style defaults, tiny bundle | defu |
| Need null to clear a default | lodash.merge |
| Arrays should replace, not grow | createDefu replace rule, or lodash with care |
| Already ship lodash | lodash.merge |
| Shallow one-level options only | Object.assign / spread |
Bottom line: defu was about 6× faster and about 9× smaller than lodash.merge on this box, and it does not mutate your defaults. Who should not switch: code that depends on lodash.merge writing nulls into the result or merging array slots by index. Drop unused deps with knip vs depcheck, and keep date helpers honest with dayjs vs date-fns if those sit next to your config layer.
How this was made: I installed defu and lodash.merge on the ShopperCove box, measured esbuild minify+gzip sizes, timed nested merges in two order-reversed runs, checked array/null/mutation behavior (including a createDefu array-replace helper), and saved the JSON results. The write-up was drafted with AI help and checked against that output.
Lab evidence
Hands-on on ShopperCove box 6 Oct 2026 ~01:45 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 22.20.0). defu 6.1.7 vs lodash.merge 4.6.2, esbuild 0.28.2 browser ESM minify + gzip -9. Fixture /workspace/bench-defu-lodash-merge/. Size: defu 827/452 B vs lodash.merge 10,647/4,119 B. Speed (50k nested merges, 9-round median, order-reversed runs): defu 427,847 / 475,272 ops/s; lodash.merge 77,085 / 75,387; Object.assign 9.44M / 8.85M; spread 13.4M / 11.7M. Behavior (res-behavior.json): defu concat arrays ["c","a","b"] vs lodash index-merge ["c","b"]; defu skips null (keeps default 1) while lodash writes null; both skip undefined; defu defaults unchanged; lodash.merge mutates target; createDefu replace-array returned ["c"] only. Raw: res-bundle.json, res-speed-run1.json, res-speed-run2.json, res-behavior.json. Not tested: full lodash, circular objects, class instances, browsers, Bun/Deno. No affiliate.