date-fns for formatting, lodash for debounce, axios for fetch, uuid for IDs, classnames for classes, and marked for Markdown — six packages that have native or near-native replacements in 2026. The experiment, the results, and when NOT to do this.
The JavaScript ecosystem has a package for everything. Need to format a date? date-fns. Need to debounce a function? lodash. Need a UUID? Install uuid. Need to combine CSS class names conditionally? Install classnames.
Every one of those install decisions is reasonable in isolation. Together, they form a bundle that ships 400KB of code to accomplish tasks the browser or JavaScript runtime can handle natively in 2026.
I ran an experiment: take a real production frontend, identify the six most-replaced packages, and replace them with native implementations. Sixty lines of code. The results were more nuanced than I expected — some replacements were obvious wins, one was a mistake, and a few surprised me with capabilities I didn’t know the platform had.
Here’s the full story.
The Starting Point
The application: a SaaS dashboard. Built with Vue 3 and Vite. The six packages:
| Package | Bundle size (gzipped) | What we used it for |
|---|---|---|
date-fns | ~18KB (tree-shaken) | Date formatting and relative time |
lodash-es | ~8KB (tree-shaken) | debounce, groupBy, orderBy |
axios | ~13KB | HTTP requests |
uuid | ~2KB | Generating unique IDs |
classnames | ~0.5KB | Conditional CSS class strings |
marked | ~22KB | Rendering Markdown in notifications |
| Total | ~63.5KB |
63.5KB gzipped is not catastrophic — it’s actually quite reasonable for these features. But it’s 63.5KB of code for things the browser either does natively or can do in a few lines. The question wasn’t whether to save space at all costs; it was whether each dependency was earning its place.
Replacement 1: date-fns → Intl.DateTimeFormat and Intl.RelativeTimeFormat
The date-fns usage we had
import { format, formatDistanceToNow, parseISO } from 'date-fns'
// Formatting a date
format(new Date(order.created_at), 'MMM d, yyyy')
// Relative time
formatDistanceToNow(parseISO(post.published_at), { addSuffix: true })
The native replacement
Intl.DateTimeFormat and Intl.RelativeTimeFormat have been available in all major browsers since 2019. They’re not just replacement functions — they’re localisation-aware by design. They respect the user’s locale without any configuration.
// Date formatting
function formatDate(date, options = {}) {
return new Intl.DateTimeFormat('en-IN', {
year: 'numeric',
month: 'short',
day: 'numeric',
...options,
}).format(new Date(date))
}
// Relative time
function formatRelativeTime(date) {
const now = Date.now()
const then = new Date(date).getTime()
const diff = then - now
const absDiff = Math.abs(diff)
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (absDiff < 60_000) return formatter.format(Math.round(diff / 1000), 'second')
if (absDiff < 3_600_000) return formatter.format(Math.round(diff / 60_000), 'minute')
if (absDiff < 86_400_000) return formatter.format(Math.round(diff / 3_600_000), 'hour')
if (absDiff < 2_592_000_000) return formatter.format(Math.round(diff / 86_400_000), 'day')
if (absDiff < 31_536_000_000) return formatter.format(Math.round(diff / 2_592_000_000), 'month')
return formatter.format(Math.round(diff / 31_536_000_000), 'year')
}
Results: 18KB saved. Better localisation — Intl.RelativeTimeFormat produces “3 days ago” vs date-fns’s “3 days ago” but with proper locale-specific phrasing (e.g. “il y a 3 jours” in French automatically, with zero configuration). The output is actually better.
When to keep date-fns: If you need date arithmetic (addDays, startOfWeek, differenceInCalendarDays), date parsing with custom formats, or timezone handling, date-fns is still the right tool. Intl handles formatting and relative time; it doesn’t do arithmetic.
Replacement 2: lodash-es → Native JavaScript
The lodash-es usage we had
import { debounce, groupBy, orderBy } from 'lodash-es'
// Debounce
const debouncedSearch = debounce(search, 300)
// Group products by category
const grouped = groupBy(products, 'category')
// Sort orders by date, then by amount
const sorted = orderBy(orders, ['created_at', 'total'], ['desc', 'asc'])
The native replacements
// debounce — 8 lines
function debounce(fn, delay) {
let timer
return function(...args) {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
// groupBy — 1 line (ES2024, Baseline since 2024)
const grouped = Object.groupBy(products, p => p.category)
// orderBy — 10 lines (handles multiple fields and directions)
function orderBy(array, fields, directions = []) {
return [...array].sort((a, b) => {
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
const dir = directions[i] === 'desc' ? -1 : 1
const aVal = a[field]
const bVal = b[field]
if (aVal < bVal) return -1 * dir
if (aVal > bVal) return 1 * dir
}
return 0
})
}
Results: 8KB saved. The debounce and orderBy implementations are straightforward. Object.groupBy is one character fewer than the lodash version and produces the same output.
When to keep lodash: If you need _.cloneDeep, _.merge, _.get/_.set for nested paths, _.uniqBy with complex comparators, or _.chunk — these have no clean single-line native equivalents. Lodash’s deep utilities are genuinely useful. The shallow utilities (debounce, groupBy, basic sort) are worth replacing.
Replacement 3: axios → Native fetch with a Thin Wrapper
The axios usage we had
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: { 'Content-Type': 'application/json' },
})
api.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${getToken()}`
return config
})
api.interceptors.response.use(
response => response.data,
error => {
if (error.response.status === 401) logout()
return Promise.reject(error)
}
)
The native fetch replacement
fetch has been Baseline since 2015. The wrapper that replicates our axios usage:
// lib/api.js — 25 lines that replace axios for our usage
const BASE_URL = import.meta.env.VITE_API_URL
async function request(path, options = {}) {
const url = `${BASE_URL}${path}`
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
...options.headers,
},
})
if (response.status === 401) {
logout()
throw new Error('Unauthorised')
}
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw Object.assign(new Error(error.message ?? 'Request failed'), {
status: response.status,
data: error,
})
}
// 204 No Content — no body to parse
if (response.status === 204) return null
return response.json()
}
export const api = {
get: (path, opts) => request(path, { method: 'GET', ...opts }),
post: (path, body, opts) => request(path, { method: 'POST', body: JSON.stringify(body), ...opts }),
put: (path, body, opts) => request(path, { method: 'PUT', body: JSON.stringify(body), ...opts }),
patch: (path, body, opts) => request(path, { method: 'PATCH', body: JSON.stringify(body), ...opts }),
delete: (path, opts) => request(path, { method: 'DELETE', ...opts }),
}
Results: 13KB saved. The fetch wrapper handles everything we were using axios for: base URL, auth header injection, 401 handling, JSON serialisation/deserialisation, error shape normalisation.
The catch: Axios gives you upload progress events, automatic request cancellation via CancelToken, and some edge-case response normalisation that fetch doesn’t do. If you need upload progress bars, keep axios. For typical JSON API calls, fetch is sufficient.
The honest admission: This was the replacement I was most nervous about and it worked the smoothest. fetch is genuinely capable for standard API usage. The wrapper is 25 lines. The test suite didn’t notice the difference.
Replacement 4: uuid → crypto.randomUUID()
The uuid usage we had
import { v4 as uuidv4 } from 'uuid'
const id = uuidv4()
// → 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
The native replacement
// Built into every modern browser and Node.js 14.17+
const id = crypto.randomUUID()
// → 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
That’s it. crypto.randomUUID() generates cryptographically secure version 4 UUIDs. It’s available in all major browsers, Node.js, Deno, and Bun. It’s faster than the uuid package because it uses the browser’s native cryptographic random number generator directly.
Results: 2KB saved. One-line replacement. Zero caveats.
When to keep the uuid package: If you need UUID versions other than v4 (v1 time-based, v3/v5 name-based), keep the package. For standard random ID generation, crypto.randomUUID() is strictly better.
Replacement 5: classnames → Template Literal + Filter
The classnames usage we had
import classNames from 'classnames'
// Conditional class names
const buttonClass = classNames('btn', {
'btn--primary': isPrimary,
'btn--disabled': isDisabled,
'btn--large': size === 'large',
})
The native replacement
// 3 lines — handles the same use cases
function clsx(...args) {
return args
.flatMap(arg =>
typeof arg === 'string' ? arg :
typeof arg === 'object' && arg !== null
? Object.entries(arg).filter(([, v]) => v).map(([k]) => k)
: []
)
.filter(Boolean)
.join(' ')
}
// Usage — identical API
const buttonClass = clsx('btn', {
'btn--primary': isPrimary,
'btn--disabled': isDisabled,
'btn--large': size === 'large',
})
Results: 0.5KB saved. The native implementation handles strings, objects, and arrays — everything the classnames package handles for standard usage.
Honest note: classnames (and its faster sibling clsx) is already tiny. This replacement saves half a kilobyte. The real reason to do it is to reduce dependency surface area, not for the bundle savings. If you’re already using clsx (which is smaller and faster than classnames), the difference is negligible.
Replacement 6: marked → The One I Regret
The marked usage we had
import { marked } from 'marked'
// Rendering markdown in notification messages
const html = marked.parse(notification.body)
The attempted native replacement
There is no native Markdown parser in the browser. I tried a minimal Markdown parser:
// My "simple" markdown parser — this is where things went wrong
function parseMarkdown(md) {
return md
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
// ... 20 more regex replacements
}
This worked for 90% of our notifications. The remaining 10%:
- Nested lists broke the regex
- Code blocks with backtick-heavy content corrupted the output
- Markdown inside HTML attributes caused injection-style rendering issues
- Escaped characters weren’t handled correctly
After two weeks, I reverted. marked is 22KB because Markdown parsing is genuinely complex. A regex-based implementation that looks adequate breaks on edge cases that are entirely predictable.
The lesson: this is exactly when NOT to do this.
Some packages are small because their problem is small. Some packages are large because their problem is large and they solved it properly. Markdown parsing is in the second category. The complexity is in the package for a reason.
What I did instead: Used marked but configured it for our specific needs (no raw HTML, sanitised output), and ensured it was code-split so it only loaded on pages that needed it. The 22KB only loads when a user opens a notification that contains Markdown — which is a small fraction of users.
The Results
| Package | Before | After | Saved | Verdict |
|---|---|---|---|---|
date-fns | ~18KB | ~3KB (custom) | ~15KB | ✅ Replace — Intl APIs are better |
lodash-es | ~8KB | ~0.5KB | ~7.5KB | ✅ Partially replace — keep deep utilities |
axios | ~13KB | ~1KB (wrapper) | ~12KB | ✅ Replace — fetch wrapper is sufficient |
uuid | ~2KB | 0KB | ~2KB | ✅ Replace — crypto.randomUUID() is better |
classnames | ~0.5KB | ~0.3KB | ~0.2KB | ⚠️ Optional — negligible difference |
marked | ~22KB | — | 0KB | ❌ Keep — complexity is real |
| Total | ~63.5KB | ~4.8KB | ~58.7KB |
Net result: ~59KB gzipped saved (excluding marked). Not the full 63.5KB — but about 60KB for five packages, which is significant.
More meaningful than the bundle size: the uuid replacement is strictly better (faster, more secure). The date-fns replacement has better localisation. The axios replacement eliminates a library abstraction layer that was adding complexity rather than removing it.
The Framework for Deciding
Before replacing any package, I now ask these questions in order:
1. Does the browser platform have a native equivalent?
crypto.randomUUID() → yes, obviously replace
fetch → yes, write a thin wrapper
Intl.DateTimeFormat → yes, more capable than most formatters
Markdown parsing → no native equivalent
2. Is the package solving a genuinely complex problem?
debounce → no, it's 8 lines
deep clone → yes, the edge cases are real
UUID v4 → no, crypto.randomUUID() is better
Markdown parsing → yes, the regex approach breaks predictably
3. What's the true bundle cost vs maintenance cost?
0.5KB classnames → probably not worth the custom code to maintain
22KB marked → worth code-splitting, not worth a broken regex parser
13KB axios → worth replacing, the wrapper is simple and testable
4. Are you replacing the whole package or just your usage?
We used 3 lodash functions → replace those 3
We didn't use axios interceptors for caching → the wrapper covers our usage
We used marked.parse() for one use case → keep marked, code-split it
What This Actually Taught Me
The platform is better than I remembered. Intl.RelativeTimeFormat is more capable than formatDistanceToNow. crypto.randomUUID() is faster and more secure than the uuid package. Object.groupBy is one line. The platform caught up — and in some cases, passed — the packages I’d been using for years.
Small packages aren’t always worth replacing. classnames is 500 bytes. Writing and maintaining a custom implementation to save 500 bytes is probably not the right trade-off. The packages that justified replacement were the ones with meaningful bundle cost (axios, date-fns) or where the native API is clearly superior (uuid).
Some packages earn their size. Marked is 22KB because Markdown parsing is genuinely complex. Deep clone is hard. Timezone handling is hard. The rule isn’t “replace everything with vanilla JS” — it’s “know why the package is the size it is before deciding whether to replace it.”
The wrapper pattern is underused. The axios replacement wasn’t “use fetch everywhere” — it was “write a 25-line wrapper that gives you the axios API you actually use.” The wrapper is testable, replaceable, and costs 1KB instead of 13KB. For libraries that provide a large API surface of which you use 5%, a wrapper is often the right call.
Code-splitting is often the better answer. For marked — and for any large package that’s only needed in specific parts of the application — code-splitting defers the cost rather than eliminating it. The 22KB only loads when it’s needed. That’s often a better trade-off than a broken regex parser.
The Honest Recommendation
Don’t do this wholesale. Do it selectively, and do it after measuring.
Run rollup-plugin-visualizer. Find the packages taking the most space. For each one, ask: is there a native equivalent? Is the problem genuinely complex? What’s the maintenance cost of the custom implementation?
The four replacements that are almost always worth it:
uuid→crypto.randomUUID()(one character, strictly better)- Basic date formatting →
Intl.DateTimeFormat(better localisation, zero code) axiosfor simple JSON APIs → a 25-line fetch wrapper- Individual lodash functions (debounce, basic sort) → 10 lines each
The replacements that are case-by-case:
- Full date-fns usage → only if you’re just formatting, not doing arithmetic
- lodash deep utilities → keep them, the edge cases are real
The replacement that almost always isn’t worth it:
- Any complex parser or transformer (Markdown, CSV, HTML) → the complexity is in the package for a reason
The 60 lines of code I wrote saved 59KB. Most of them were worth writing. One of them was a mistake I reverted after two weeks. That ratio is probably about right for this kind of experiment.
