How to minify JavaScript files using Terser

How to minify JavaScript files using Terser

When you’re developing software, the primary goal is to make it work. You can always optimize later, but first, you need to ensure that the functionality is in place. This is often easier said than done, especially when you’re staring at lines of code that seem to spiral into complexity.

Take a simple function, for instance. It’s crucial to get the logic right before you even think about refactoring. Here’s a basic example of a function that calculates the factorial of a number:

function factorial(n) {
  if (n < 0) return undefined; // Factorial is not defined for negative numbers
  if (n === 0) return 1; // Base case
  return n * factorial(n - 1); // Recursive case
}

Now, this function does the job. It will return the correct factorial for any non-negative integer. However, it’s not the most efficient way to do it, especially for larger numbers due to the potential stack overflow from too many recursive calls. But we aren’t worried about that yet. The important part is that it works.

Once you have the functionality nailed down, you can start looking at ways to optimize it. One common approach is to use an iterative solution, which can help alleviate the issues associated with recursion:

function factorialIterative(n) {
  if (n < 0) return undefined;
  let result = 1;
  for (let i = 2; i <= n; i++) {
    result *= i;
  }
  return result;
}

This iterative version is more efficient in terms of memory usage. But the key takeaway is that before you start worrying about performance or code length, you should focus on correctness. Once you have a working solution, the next step is to make it small, to eliminate any unnecessary code, and to simplify the logic. This is where the fun begins, as you get to play around with different algorithms and approaches.

There’s a certain satisfaction in refining your code, like polishing a gemstone. You’ll find that the clearer and more concise your code becomes, the easier it is to maintain and understand. It’s a bit like cleaning out your closet; once you get rid of the clutter, you can see what you truly have and appreciate it more.

But let’s not get ahead of ourselves. There’s still a magic command that can help us automate some of this process…

The one command that does all the magic

So, what is this magic command? It’s not an ancient incantation, it’s your build script. In the world of modern JavaScript, it’s usually something innocuous-looking like npm run build, which you type into your terminal. You press Enter, and a fraction of a second later, a folder named dist or build appears, containing a version of your code that is lean, mean, and completely unintelligible to humans.

This command is the bridge between two worlds. In one world, you write code for other programmers. You use meaningful variable names, you break your code into logical modules, and you write comments to explain the tricky parts. This code is designed to be read, understood, and maintained. In the other world, the one the browser lives in, every byte counts. The browser doesn’t care that you named a variable userProfileData instead of d. It just wants the smallest possible file to download and execute as quickly as possible.

Let’s look at a trivial example. Imagine you have a file with some helper functions, because you are a good programmer who believes in the Don’t Repeat Yourself principle.

// helpers.js
export const add = (a, b) => {
  // A function to add two numbers
  return a + b;
};

export const subtract = (a, b) => {
  // A function to subtract two numbers
  return a - b;
};

And here is your main application logic, which uses one of those helpers.

// main.js
import { add } from './helpers.js';

const initialValue = 10;
const valueToAdd = 5;

const result = add(initialValue, valueToAdd);

console.log(The final result is ${result}.);

You’ve written clear, modular code. Now, you need to prepare it for the browser. You open your terminal and run the one command that does all the magic. Let’s say you’re using a bundler like Rollup or Webpack.

npm run build

The bundler springs to life. It reads main.js, follows the import statement to helpers.js, and sees the entire dependency graph. Then it gets to work. It sees that you never used the subtract function, so it throws it out. This is called tree-shaking. It sees that initialValue and valueToAdd are constants, so it might inline them. It strips out all your comments and unnecessary whitespace. It renames variables. The result is a dense, compact blob of code.

const o=10,t=5,c=o+t;console.log(The final result is ${c}.);

This single line of code does exactly the same thing as your original two files, but it’s a fraction of the size. The browser can download and parse it much faster. This transformation is the single most important optimization you can do for a web application, and it’s all handled by one command. You get to write beautiful, maintainable code, and your users get a fast, optimized experience. It’s the best of both worlds, and it’s all thanks to the build process.

Twiddling the knobs to squeeze out every last byte

The default build script is your blunt instrument. It gets 90% of the job done, and for many projects, that’s good enough. But when you’re building a large-scale application, that last 10% is where performance battles are won or lost. This is where you pop the hood on your bundler-be it Webpack, Rollup, or esbuild-and start twiddling the knobs. These “knobs” are the configuration options that let you fine-tune the entire optimization process.

One of the most impactful knobs you can turn is code splitting. The default behavior of a bundler is to create one giant JavaScript file, a bundle.js that contains every single line of code for your entire application. The user has to download this behemoth just to see the login screen, even if the code for the super-fancy, data-heavy dashboard they might never visit is included. Code splitting allows you to break that giant bundle into smaller chunks that can be loaded on demand.

The magic syntax for this is the dynamic import() function, which looks like a function call but is actually a special operator that the bundler understands. When the bundler sees it, it automatically creates a separate chunk for the imported module.

// Instead of this at the top of your file:
// import { showAdminDashboard } from './adminDashboard.js';

const adminButton = document.getElementById('admin-button');

adminButton.addEventListener('click', () => {
  // The code for adminDashboard.js is only downloaded
  // when the user clicks this button.
  import('./adminDashboard.js').then(module => {
    module.showAdminDashboard();
  }).catch(err => {
    console.error("Failed to load admin module", err);
  });
});

When you run the build command, the bundler will now produce at least two files: your main bundle and a separate chunk for adminDashboard.js. Your initial page load is now faster because the user isn’t downloading code they might not need. This is a huge win, especially for applications with role-based features or complex, multi-step wizards.

Another set of knobs controls the minifier. Tools like Terser are incredibly powerful and have a dizzying array of options. The bundler’s default settings are usually a safe, conservative balance between size reduction and potential breakage. But you can push it further. For example, the compress option in Terser has sub-options like dead_code and pure_funcs. You can tell it that a function like console.log has no side effects on the program’s logic, allowing the minifier to remove it entirely from a production build.

// Your original, debug-friendly code
function processData(data) {
  console.log('Processing started');
  // ... complex logic ...
  const result = data.value * 10;
  console.log('Processing finished');
  return result;
}

By configuring your minifier to treat console.log as a “pure” function, the build process can transform that code into its bare essentials, because it knows the function calls don’t affect the return value.

// After aggressive minification
function processData(a){return a.value*10}

Then there’s scope hoisting, or as Rollup calls it, module concatenation. Normally, a bundler wraps each of your modules in a function to prevent variable name collisions. This adds a small amount of overhead in both file size and runtime execution speed. Scope hoisting is an optimization that tries to stitch all your modules together into a single, larger scope, eliminating those function wrappers. This results in smaller bundles and faster code. You don’t write any special code for this; you just turn on the right flag in your bundler’s configuration, and it analyzes your dependency graph to see where it can safely flatten your modules. It’s one of those optimizations that just works, silently making your code better without you having to think about it, provided you’ve enabled the knob.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

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