Surviving TypeScript and Webpack in 2026 (When Everyone Else Moved On)

Look, I know exactly what you’re thinking. Why am I writing about Webpack right now? Half the industry just finished migrating to Vite, and the other half is playing with Turbopack. I get it. If I were starting a greenfield project today, I wouldn’t touch Webpack with a ten-foot pole.

But here’s the reality check. I maintain a massive enterprise dashboard. It has over 400 custom Webpack plugins written by developers who left the company three years ago. “Just rewrite it in Vite” is a great punchline on social media, but it’s not a Jira ticket my product manager is going to approve anytime soon.

So, we optimize. We make it work.

The ts-loader Bottleneck

If you’re still using ts-loader in your Webpack config, I need you to stop. Seriously. It’s doing way too much work on the main thread.

Webpack logo - Webpack JS logo
Webpack logo – Webpack JS logo” Sticker for Sale by hipstuff | Redbubble

I spent three hours last Tuesday trying to figure out why our CI pipeline was choking. Turns out, running full TypeScript type-checking during the emit phase is a terrible idea for large codebases. On our staging cluster with 3 nodes (running Node.js 22.1.0), the memory spikes were consistently hitting the 4GB V8 limit. I kept getting the dreaded Error: ENOMEM - ran out of memory right before the build finished.

The fix? Ditch it entirely for swc-loader and move the type checking to a separate background process.

// webpack.config.js
const path = require('path');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');

module.exports = {
  entry: './src/index.ts',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: {
          loader: 'swc-loader',
          options: {
            jsc: {
              parser: {
                syntax: "typescript",
                tsx: true
              },
              target: "es2022"
            }
          }
        },
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  plugins: [
    // This is what saves your memory limit
    new ForkTsCheckerWebpackPlugin()
  ]
};

Making this single swap cut our production build time from 8m 23s to 1m 14s. I actually got up to get coffee and came back to a finished build. Felt weird.

Writing Actual TypeScript (Without Framework Bloat)

Now that the build doesn’t make you want to cry, let’s talk about the actual code going through it. One thing I constantly see junior devs mess up is handling asynchronous API calls and DOM updates safely.

TypeScript is notoriously strict about DOM types. This is a good thing, but it leads to a lot of ugly as HTMLElement casting if you don’t know what you’re doing. Here’s a pattern I use almost daily for fetching data and rendering it directly to the DOM. No massive frameworks, just clean, typed JS.

Webpack logo - Webpack: The Ultimate Guide. Webpack is a powerful static module ...
Webpack logo – Webpack: The Ultimate Guide. Webpack is a powerful static module …
// src/api-widget.ts
interface UserData {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

// Handling the async API fetch safely
async function fetchActiveUsers(endpoint: string): Promise<UserData[]> {
  try {
    const response = await fetch(endpoint, {
      headers: { 'Accept': 'application/json' }
    });

    if (!response.ok) {
      throw new Error(HTTP error! status: ${response.status});
    }

    const data: UserData[] = await response.json();
    return data.filter(user => user.isActive);
    
  } catch (error) {
    console.error("Failed to grab users:", error);
    return [];
  }
}

// Safe DOM manipulation
export async function initializeUserList(containerId: string): Promise<void> {
  // The strict null check here saves you from 'Cannot read property of undefined'
  const container = document.getElementById(containerId) as HTMLDivElement | null;

  if (!container) {
    console.warn(Container #${containerId} missing from the DOM.);
    return;
  }

  container.innerHTML = '<p class="loading-state">Fetching users...</p>';

  const users = await fetchActiveUsers('https://api.example.com/v2/users');

  // Clear the loading text
  container.innerHTML = '';

  if (users.length === 0) {
    container.textContent = 'No active users found right now.';
    return;
  }

  const list = document.createElement('ul');
  list.className = 'user-directory';

  users.forEach(user => {
    const listItem = document.createElement('li');
    
    // Template literals with safe TS property access
    listItem.textContent = ${user.name} (${user.email});
    listItem.dataset.userId = user.id.toString();

    // Event listeners need explicit typing for the event target
    listItem.addEventListener('click', (e) => {
      const target = e.target as HTMLLIElement;
      console.log(Clicked user ID: ${target.dataset.userId});
      
      // Visual feedback
      target.classList.toggle('selected-user');
    });

    list.appendChild(listItem);
  });

  container.appendChild(list);
}

The Polyfill Gotcha

Here’s a fun one that bit me hard last month. If you’re using swc-loader (like I just told you to), watch your async/await transformations.

I ran into a bizarre edge case where our final bundle size ballooned by almost 400KB overnight. I dug into the output and realized SWC was aggressively polyfilling generator functions for older browser targets that we didn’t even support anymore. It was turning every clean async/await block into an unreadable mess of state machines.

The docs barely mention this, but if your browserslist file is missing or misconfigured, the bundler assumes you want to support ancient browsers. I pinned ours strictly to last 2 Chrome versions and the bundle size dropped immediately. Always check what your loader is actually outputting.

software developer coding - Commonly Confused Titles in Software Development | by Todd Cullum ...
software developer coding – Commonly Confused Titles in Software Development | by Todd Cullum …

Looking Ahead

Am I going to use Webpack forever? God, no.

I expect the remaining enterprise Webpack holdouts to migrate to Rspack by Q1 2027. It’s basically a drop-in Rust replacement for Webpack, which means you don’t have to rewrite your custom plugins from scratch. It’s the only realistic escape hatch for massive legacy apps.

But until my team gets the green light for that migration, I’m stuck with this config. If you’re in the same boat, just switch to SWC, separate your Questions readers ask

Why is ts-loader slowing down my Webpack build so much?

ts-loader runs full TypeScript type-checking during the emit phase on the main thread, which is brutal for large codebases. On a 3-node staging cluster running Node.js 22.1.0, memory spikes consistently hit the 4GB V8 limit, throwing ENOMEM errors right before builds finished. Replacing ts-loader with swc-loader and offloading type checks to ForkTsCheckerWebpackPlugin cut one production build from 8m 23s down to 1m 14s.

How do I safely cast DOM elements in strict TypeScript without using ‘as HTMLElement’ everywhere?

Cast with a nullable union like `document.getElementById(containerId) as HTMLDivElement | null`, then guard with an early return if the element is missing. This satisfies TypeScript’s strict null checks and prevents ‘Cannot read property of undefined’ errors at runtime. For event listeners, type the event target explicitly (e.g. `e.target as HTMLLIElement`) so you can access properties like `dataset.userId` without fighting the compiler.

Why did my bundle size suddenly grow by hundreds of KB after switching to swc-loader?

SWC aggressively polyfills async/await and generator functions when your browserslist config is missing or misconfigured, assuming you need to support ancient browsers. This can balloon bundles by around 400KB overnight, converting clean async/await blocks into unreadable state machines. Pinning browserslist strictly to something like `last 2 Chrome versions` immediately drops the bundle size. Always inspect your loader output to catch this.

Should I migrate a legacy Webpack app with hundreds of custom plugins to Vite or Rspack?

For massive legacy apps with hundreds of custom Webpack plugins, Vite isn’t realistic — rewriting plugins from scratch isn’t a product-manager-approved ticket. Rspack is the practical escape hatch: it’s a Rust-based drop-in replacement for Webpack, so custom plugins carry over. The article’s author expects remaining enterprise Webpack holdouts to migrate to Rspack by Q1 2027, and plans to stick with optimized Webpack until then.

typescriptworld_com

Learn More →

Leave a Reply

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