RSS Amplifier

Sarthak Mishra · Jan 11, 2026

Honey, I Shrunk my Frontend Bundle Size!

0
Sign in to vote or save

Sarthak Mishra · Sarthak Mishra

We love dropping big libraries on small problems. Need an animated icon? Install lottie-react. Need a toast? Install sonner. Need a hover effect? Install react-spring.

It works until you look at the bundle.

After adding rollup-plugin-visualizer, I found my sidebar icons alone were pulling ~200KB of JavaScript. React added another ~40KB. Time to trim.

Lighthouse score after optimization

The Problem: Lottie was crushing performance

lottie-web plus its React wrapper were bundled just to animate five icons. That hit first paint, parse time, and memory. A 40-frame interaction does not need a full animation engine.

Solution: Canvas sprite sheets

I reused the sprite-sheet approach from my hero animation.

  • Input: a 7x6 PNG (41 frames)
  • Logic: requestAnimationFrame + drawImage
  • Trigger: only runs when isAnimating is true (hover)

Result: ~3KB instead of ~210KB.

// SpriteIcon.tsx (simplified)
import { useEffect, useRef } from 'preact/hooks';

export default function SpriteIcon({ spriteName, isAnimating }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  // Pure Canvas animation
  return <canvas ref={canvasRef} />;
}

Migration 1: React to Preact (~40KB saved)

For a content-heavy site, I do not need full React. Preact gives a near-identical API at ~3KB. I began with compat mode, then moved components to Preact primitives over time.

// astro.config.mjs
import preact from '@astrojs/preact';

export default defineConfig({
  integrations: [preact({ compat: true })],
  vite: {
    resolve: {
      alias: [
        { find: 'react-dom/server', replacement: 'preact-render-to-string' },
        {
          find: 'preact/compat/server',
          replacement: 'preact-render-to-string',
        },
      ],
    },
  },
});

Migration 2: Ditching react-spring (~20KB saved)

I was using react-spring for simple hover transitions. A physics engine for a 50px move is wasteful. Native CSS transitions with a custom cubic-bezier did the job.

Before:

const style = useSpring({ transform: `translateY(${position}px)` });

After:

<div
  style={{
    transform: `translateY(${position}px)`,
    transition: 'transform 400ms cubic-bezier(0.20, 0.90, 0.20, 1.00)'
  }}
/>

Migration 3: Custom toast (~20KB saved)

sonner is great, but overkill here. I replaced it with ~120 lines of vanilla TypeScript that manipulates the DOM directly.

import { toast } from '@/lib/toast';
toast.error('Something went wrong');

Here’s the full implementation:

/**
 * Standalone toast notifications for use in Astro pages and scripts
 * This is a vanilla TS implementation that doesn't depend on React
 */

type ToastType = 'error' | 'warning' | 'success' | 'info';

const styleConfig = {
  error: {
    iconColor: '#DC2626',
    iconPath:
      '<path d="M13 7L7 13M7 7L13 13" stroke="white" stroke-width="2" stroke-linecap="round"/>',
  },
  warning: {
    iconColor: '#F59E0B',
    iconPath:
      '<path d="M10 6V10M10 13V13.01" stroke="white" stroke-width="2" stroke-linecap="round"/>',
  },
  success: {
    iconColor: '#16A34A',
    iconPath:
      '<path d="M6.5 10L9 12.5L14 7.5" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
  },
  info: {
    iconColor: '#2563EB',
    iconPath:
      '<circle cx="10" cy="6" r="1.25" fill="white"/><path d="M10 9V14" stroke="white" stroke-width="2" stroke-linecap="round"/>',
  },
};

let toastContainer: HTMLDivElement | null = null;

function getToastContainer(): HTMLDivElement | null {
  if (typeof document === 'undefined') return null;

  if (!toastContainer || !document.body.contains(toastContainer)) {
    toastContainer = document.createElement('div');
    toastContainer.className = 'toast-container'; // Fixed positioning, top-right
    document.body.appendChild(toastContainer);
  }
  return toastContainer;
}

function createToast(type: ToastType, message: string): void {
  const container = getToastContainer();
  if (!container) return;

  const config = styleConfig[type];
  const wrapper = document.createElement('div');
  wrapper.className = 'toast-wrapper'; // Slide-in animation

  wrapper.innerHTML = `
    <div class="toast" role="alert">
      <svg width="20" height="20" viewBox="0 0 20 20">
        <rect width="20" height="20" rx="5" fill="${config.iconColor}"/>
        ${config.iconPath}
      </svg>
      <p>${message}</p>
      <button class="toast-close" aria-label="Close">×</button>
    </div>
  `;

  container.appendChild(wrapper);

  // Animate in
  requestAnimationFrame(() => {
    wrapper.classList.add('visible');
  });

  const removeToast = () => {
    wrapper.classList.remove('visible');
    setTimeout(() => wrapper.remove(), 200);
  };

  const closeBtn = wrapper.querySelector('.toast-close');
  closeBtn?.addEventListener('click', removeToast);

  // Auto-dismiss after 5 seconds
  setTimeout(removeToast, 5000);
}

export const toast = {
  error: (message: string) => createToast('error', message),
  warning: (message: string) => createToast('warning', message),
  success: (message: string) => createToast('success', message),
  info: (message: string) => createToast('info', message),
};

Same UX, zero dependencies, works outside the component tree.

Optimizing the critical path

PageSpeed showed 2,769ms max critical path latency. The main offenders: analytics and my pixelation effect loading too early.

1) Defer analytics

Load analytics in requestIdleCallback, fall back to load if unavailable.

<script is:inline>
  function loadAnalytics() {
    // append script...
  }

  if ('requestIdleCallback' in window) {
    requestIdleCallback(loadAnalytics, { timeout: 2000 });
  } else {
    window.addEventListener('load', loadAnalytics);
  }
</script>

2) Smarter pixelation

  • Disable on mobile (performance over aesthetics)
  • Batch reads/writes with requestAnimationFrame
  • Use offsetWidth when possible to leverage cached layout

The results

  • Lottie removal: ~230KB saved
  • React → Preact: ~40KB saved
  • Spring + sonner: ~40KB saved
  • Total: ~310KB (gzipped)

Critical path latency dropped from ~2.7s to non-blocking. The site feels snappier. Animations stay smooth on low-end devices because they are CSS-native or Canvas-based.

Bundle stats: before and after optimization

Takeaways

Dependencies are debt on every page load.

You do not need a physics engine for hover states. You do not need a full React runtime for a static blog. You do not need 200KB of JSON player logic to wiggle an icon.

Audit your bundle. The numbers will surprise you.

Read the original on sarthakmishra.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.