I Replaced 6 npm Packages With 60 Lines of Vanilla JavaScript. Here’s What I Learned.

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:

PackageBundle 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~13KBHTTP requests
uuid~2KBGenerating unique IDs
classnames~0.5KBConditional CSS class strings
marked~22KBRendering 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

PackageBeforeAfterSavedVerdict
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~2KB0KB~2KB✅ Replace — crypto.randomUUID() is better
classnames~0.5KB~0.3KB~0.2KB⚠️ Optional — negligible difference
marked~22KB0KB❌ 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:

  • uuidcrypto.randomUUID() (one character, strictly better)
  • Basic date formatting → Intl.DateTimeFormat (better localisation, zero code)
  • axios for 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.

Leave a Reply

Your email address will not be published. Required fields are marked *