We’ve all been there. You run npx create-react-app or initialize a new Next.js project, and before you’ve written a single line of business logic, your node_modules folder is heavier than a black hole, and your build step takes long enough for you to grab a coffee. Modern web development is powerful, but it has undeniably normalized bloat.

But what if we strip it all away? What if we go back to the roots of the web platform, HTML, CSS, and JavaScript and set a strict limit?

Welcome to the 1MB Challenge: Build a fully functional, modern Single Page Application (SPA) with zero dependencies and a total uncompressed payload of under 1 megabyte.

Here is how to pull it off, the tech details behind it, and the vanilla patterns that make it possible.

The Rules of Engagement

  1. Zero External Dependencies: No React, Vue, Angular, Svelte, Tailwind or Lodash. None. Everything must be written from scratch using browser APIs.
  2. Under 1MB Uncompressed: The entire application (HTML, CSS, JS and essential assets) must weigh less than 1,024 KB.
  3. Modern UX: It must feel like an SPA. That means client-side routing, reactive UI components and smooth state transitions.

1. Replacing the Framework: Vanilla Web Components

You don’t need React to build reusable UI. The browser natively supports Web Components allowing you to encapsulate HTML, CSS and JS into custom tags.

By extending HTMLElement we can create a reactive component that manages its own DOM updates.

// component.js
class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  static get observedAttributes() {
    return ['username', 'role'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render();
    }
  }

  connectedCallback() {
    this.render();
  }

  render() {
    const username = this.getAttribute('username') || 'Guest';
    const role = this.getAttribute('role') || 'User';

    this.shadowRoot.innerHTML = `
      <style>
        .card { 
          padding: 1rem; 
          border: 1px solid #ddd; 
          border-radius: 8px;
          font-family: system-ui;
        }
        h2 { margin: 0 0 0.5rem 0; font-size: 1.25rem; }
        p { margin: 0; color: #666; }
      </style>
      <div class="card">
        <h2>${username}</h2>
        <p>${role}</p>
      </div>
    `;
  }
}

customElements.define('user-card', UserCard);

Usage in HTML:

<user-card username="makalin" role="Lead Developer"></user-card>

2. Replacing Redux: Reactive State with Proxies

Global state management is often the biggest excuse for bringing in a heavy library. However, modern JavaScript gives us the Proxy object which allows us to intercept and redefine fundamental operations for an object (like reading or writing properties).

Combined with a simple Publish-Subscribe (PubSub) pattern you can build a robust state manager in about 30 lines of code.

// store.js
class Store {
  constructor(initialState) {
    this.listeners = [];
    
    this.state = new Proxy(initialState, {
      set: (target, key, value) => {
        target[key] = value;
        this.notify(key, value);
        return true;
      }
    });
  }

  subscribe(fn) {
    this.listeners.push(fn);
    // Return unsubscribe function
    return () => {
      this.listeners = this.listeners.filter(listener => listener !== fn);
    };
  }

  notify(key, value) {
    this.listeners.forEach(listener => listener(key, value, this.state));
  }
}

// Initialize Global Store
const appStore = new Store({ userCount: 0, theme: 'light' });

// Listen for changes
appStore.subscribe((key, value, state) => {
  console.log(`State updated! ${key} is now ${value}`);
  // Trigger DOM updates here
});

// Mutate state (triggers the proxy and notifies listeners)
appStore.state.userCount = 1; 

3. Replacing React Router: The History API

Client-side routing ensures the app feels instantaneous. Instead of importing a 10KB routing library we can hook directly into the browser's native History API and listen to the popstate event.

// router.js
const routes = {
  '/': () => '<h1>Home</h1><p>Welcome to Digital Vision.</p>',
  '/about': () => '<h1>About</h1><p>Zero dependencies, pure speed.</p>',
  '/404': () => '<h1>404</h1><p>Page not found.</p>'
};

const appDiv = document.getElementById('app');

const renderRoute = () => {
  const path = window.location.pathname;
  const component = routes[path] || routes['/404'];
  appDiv.innerHTML = component();
};

const navigate = (path) => {
  window.history.pushState({}, '', path);
  renderRoute();
};

// Handle browser back/forward buttons
window.addEventListener('popstate', renderRoute);

// Intercept link clicks
document.body.addEventListener('click', e => {
  if (e.target.matches('[data-link]')) {
    e.preventDefault();
    navigate(e.target.getAttribute('href'));
  }
});

// Initial render
renderRoute();

The Ecological and Performance Impact

When you rely entirely on the native web platform, the results are staggering:

Metric

Typical React SPA

Zero-Dependency SPA

JS Bundle Size

250KB - 800KB+

~10KB - 25KB

Time to Interactive (TTI)

~1.5 - 3.5 seconds

< 100ms

Build Time

10s - 60s+

0s (No build step!)

By shedding the framework layer the browser doesn't have to parse, compile and execute hundreds of kilobytes of third-party JavaScript before rendering your UI. This translates directly to perfect Lighthouse scores, lower data consumption for mobile users and a vastly reduced carbon footprint for your hosting infrastructure.

Final Note

Stepping outside the comfort zone of modern tooling reminds us of how capable the native web platform has actually become. While frameworks absolutely have their place in massive, enterprise-scale applications, challenging yourself to build without them is the best way to master the underlying technology.