Plate 33
ufo vs URL: 2.4 KB vs 0.5 KB Gzip
Aditya Challa5 min read
I bundled URL helpers on this box: ufo 1.6.4 came out at 2,374 bytes gzip for six helpers, while a small WHATWG URL shim covering the same calls came out at 519 bytes. joinURL favored ufo (~0.22 µs vs ~2.5 µs). normalizeURL favored the URL constructor (~0.47 µs vs ~3.4 µs) because ufo does not resolve ./ and ../.
Short answer: pick ufo when you want string-first join/query helpers that stay relative-friendly and match the unjs stack. Keep native URL when you need real path normalization, default-port stripping, and zero extra bytes. No affiliate links in this post.
What I tested
- Machine: 8 vCPU Intel Xeon / 15 GB RAM shared Linux cloud box, tested 6 Oct 2026 about 02:15 IST.
- Versions: ufo 1.6.4, Node 22.20.0 WHATWG URL, esbuild 0.28.2 browser ESM minify + gzip -9.
- Inputs: join of CDN base + three segments; withQuery on /search with a space and page key; parseURL with host:443, query, and hash; normalizeURL with mixed case and ./ ../ segments. Speed: 200,000 calls, median of 9 rounds, two order-reversed runs. Scripts in /workspace/bench-ufo-url/.
- Findings: ufo core 5,041 / 2,374 B vs shim 1,195 / 519 B. joinURL ~227.7 / 221.6 ns (ufo) vs ~2,488 / 2,593 ns (URL shim). withQuery ~2,256 / 2,351 vs ~1,693 / 1,673. parseURL ~564 / 539 vs ~639 / 609. normalizeURL ~3,296 / 3,491 vs ~476 / 461.
- One surprise: ufo.normalizeURL("https://A.com/./b/../c") left the dots in place; new URL resolved to https://a.com/c and lowercased the host. parseURL also kept host a.com:443 on ufo while URL dropped the default HTTPS port.
- Not tested: browsers other than the esbuild browser target, unicode hostnames, IPv6 literals, or createURL / $URL class paths.
How much does ufo cost?
| Build (esbuild minify, gzip -9) | Minified | gzip |
|---|---|---|
| ufo six helpers (joinURL, withQuery, parseURL, normalizeURL, withoutTrailingSlash, getQuery) | 5,041 B | 2,374 B |
| WHATWG URL shim with the same six names | 1,195 B | 519 B |
Native URL is already in the runtime, so the true incremental cost of "just use URL" is closer to zero than 519 B. The shim exists only so the size table compares similar export surfaces. Track deltas the same way as size-limit vs bundlesize. Sibling unjs vs stdlib posts include pathe vs node:path and scule vs change-case.
Do the outputs match?
| Check | ufo 1.6.4 | WHATWG URL shim |
|---|---|---|
| joinURL('/api','v1','users') | /api/v1/users | /api/v1/users |
| joinURL('https://a.com/','/b/','c') | https://a.com/b/c | https://a.com/b/c |
| withQuery('/x', {q:'a b', n:1}) | /x?q=a+b&n=1 | /x?q=a+b&n=1 |
| parseURL host for :443 | a.com:443 | a.com |
| normalizeURL dots / case | https://A.com/./b/../c | https://a.com/c |
| withoutTrailingSlash('/docs/') | /docs | /docs |
Join and withQuery matched on my fixtures. Behavior diverges on default ports and on path-segment cleanup. If your app already leans unjs for HTTP, compare ky vs axios. Cookie-header helpers sit nearby in cookie-es vs cookie.
Is joinURL speed enough to justify ufo?
| Cost (200k calls, median of 9, 2 runs, ns/op) | ufo | URL shim |
|---|---|---|
| joinURL | 227.7 / 221.6 | 2,488.3 / 2,593.2 |
| withQuery | 2,255.5 / 2,350.8 | 1,693.2 / 1,673.4 |
| parseURL | 563.6 / 539.0 | 638.5 / 609.1 |
| normalizeURL | 3,295.8 / 3,491.3 | 475.8 / 461.4 |
joinURL was about 11× faster on ufo here because it stays in string space. withQuery and normalizeURL favored the URL constructor. For route builders that call join thousands of times per request, that join win is real. For one-off redirects, size and normalize behavior matter more. Hashing and clone helpers in the same series: ohash vs object-hash and klona vs structuredClone.
Which one should you pick?
| Situation | My pick |
|---|---|
| Nuxt / unjs stack, relative joins, query helpers | ufo |
| Need ./ ../ resolution and host lowercasing | WHATWG URL |
| Bundle budget under a few kilobytes for URL utils | WHATWG URL |
| Hot joinURL in a CDN/asset path builder | ufo |
| Parsing absolute URLs with default ports | decide on port policy first, then pick |
Bottom line: ufo 1.6.4 was 2,374 B gzip for six helpers versus 519 B for a URL shim, with joinURL about 11× faster and normalizeURL much slower because dots stay literal. Who should not add ufo: apps that only call new URL a handful of times and already rely on WHATWG normalization. Config merge next door: defu vs lodash.merge.
How this was made: I installed ufo on the ShopperCove box, wrote a matching URL shim, measured esbuild bundles, timed four helpers in two order-reversed runs, and compared outputs on shared fixtures. The JSON results sit next to the scripts. The write-up was drafted with AI help and checked against that output.
Sources
- https://github.com/unjs/ufo
- https://developer.mozilla.org/en-US/docs/Web/API/URL
- https://www.npmjs.com/package/ufo
- https://nodejs.org/api/url.html
Related
- https://www.shoppercove.com/blog/size-limit-vs-bundlesize
- https://www.shoppercove.com/blog/pathe-vs-node-path
- https://www.shoppercove.com/blog/scule-vs-change-case
- https://www.shoppercove.com/blog/ky-vs-axios
- https://www.shoppercove.com/blog/cookie-es-vs-cookie
- https://www.shoppercove.com/blog/ohash-vs-object-hash
- https://www.shoppercove.com/blog/klona-vs-structuredclone
- https://www.shoppercove.com/blog/defu-vs-lodash-merge
Lab evidence
What I found running this
Hands-on on ShopperCove box 6 Oct 2026 ~02:15 IST (8 vCPU Intel Xeon / 15 GB shared Linux, Node 22.20.0). ufo 1.6.4 vs WHATWG URL shim, esbuild 0.28.2 browser ESM minify + gzip -9. Size: ufo six helpers 5,041/2,374 B vs shim 1,195/519 B. Speed (200,000 calls, 9-round median, order-reversed, ns/op): joinURL ufo 227.7 / 221.6 vs URL 2,488.3 / 2,593.2; withQuery 2,255.5 / 2,350.8 vs 1,693.2 / 1,673.4; parseURL 563.6 / 539.0 vs 638.5 / 609.1; normalizeURL 3,295.8 / 3,491.3 vs 475.8 / 461.4. Behavior: join/withQuery matched; ufo.normalizeURL left ./ ../; URL resolved and lowercased host; parseURL host a.com:443 vs a.com. Not tested: other browsers, unicode hosts, IPv6, createURL/$URL. No affiliate.