How we made CKEditor 40% Smaller: A Deep Dive into Bundle Size Optimization
In this article, we’ll show how we cut CKEditor 5’s bundle size by 40% through tree-shaking and bundle size optimization techniques and share tricks you can use to slim down your own JavaScript libraries. We’ll also walk through the tools and processes we used to achieve this, measure improvements, and catch regressions.
As any software library author will tell you, building and maintaining is hard. You not only have to write the code, document and test it (as any project should), but you also have to make sure that the library is easy to use, accessible, fast, and that it works with different frontend and backend environments. Among the many factors that affect JavaScript performance, bundle size optimization is one of the most critical.
That’s especially true for a highly modular framework like CKEditor 5, which has over 80 open-source and commercial packages. More packages mean more moving parts and more opportunities for extra bytes to sneak into your bundle. But the path to making bundles smaller isn’t always straightforward.
The starting point for bundle size optimization
To set the scene, let’s rewind to late 2023 – and I promise this is relevant. This is when we kicked off our “Madagascar” initiative (a nod to the “Hurry up, before we come to our senses!” line in the Madagascar 2 movie).
The theme of this initiative was to make CKEditor 5 more developer-friendly. From the beginning, we knew that the scope was massive and involved multiple areas - from the release process and distribution, through installation and configuration, and finally to documentation supporting the integrator’s developer experience.
You can read more about the whole initiative in our CKEditor 5: A New Era of Installation Simplicity post. (If you have already migrated to the new installation methods, let us know how it went.)
One of the areas that we addressed as part of the refactoring distribution and installation was optimizing the bundle size of CKEditor. JavaScript bundle size optimization includes minification, compression, tree-shaking, code splitting, and smart dependency management – all crucial for modern JavaScript applications. In this article, we’ll dive into this journey, which turned out to be one of the most complex challenges that we had to tackle.
Distribution and installation methods: the foundation of bundle optimization
What do we actually mean when we say the “distribution and installation methods”?
For those not familiar with CKEditor 5, let’s start with a bit more context:
-
CKEditor 5 is a front-end component: it can be installed in any web application, from vanilla JavaScript to React, Angular, Vue, Svelte, Next.js, Nuxt, and even the browser running on your fridge’s OS.
-
It consists of multiple asset types: JavaScript source, TypeScript typings, translations (JavaScript files), icons (SVGs), and stylesheets (CSS).
-
It’s highly modular: currently, it consists of over 85 official npm packages. Each package may expose multiple editor features, and most deliver their own translations, icons, and stylesheets.
The scale is quite significant, and to make things even more complex for us, unlike a framework, we can’t ship an entire build system or a project template and expect integrators to base their projects on it. Instead, CKEditor needs to integrate seamlessly with the least effort into many potential existing setups.
So, “distribution” answers the question of how engineers obtain the source, and “installation” is how they eventually use it in their setups.
How we lost control: the bundle size challenge
Most JavaScript libraries usually have two distribution and installation paths: one for npm and perhaps a dedicated build that works over a CDN. Back in 2023, 8 years after the first lines of CKEditor 5 code were written, we ended up having more than half a dozen different methods.
What went wrong?
The first challenge was complexity. Our ecosystem is highly modular (again, over 80 packages, delivering over 100 features, consisting of not only the code itself but also translations, styles, and icons). With so many moving parts, we have to consider the following:
-
While developers using npm can pull the dependencies they need, on CDN the options are much more limited before the network becomes a bottleneck. We also cannot bundle all the packages together because that bundle would be massive.
-
For all the packages to work properly and not be duplicated, they must be installed in the exact same version. However, with so many packages, it is very common for developers to install different versions. Occasionally, package managers update some to the latest version, leaving the rest untouched.
-
The translations and styles must be imported alongside the code. This works fine in small setups that depend on a few packages, but in more complex systems it becomes increasingly difficult to configure properly and later maintain and update. Imagine an editor setup with imports from over 100 paths (code + styles + translations).
The second challenge was the evolution of the surrounding ecosystem. Back in 2015, when the first commits to the project were made, the JavaScript ecosystem was very different – ES Modules and webpack were a “new hot thing,” frontend frameworks were just starting to gain traction, and most developers were still wrestling with jQuery. Back then, we couldn’t imagine how the JavaScript ecosystem would evolve.
This resulted in different installation methods being introduced over time to fix the shortcomings of the previous ones. Each method served a different purpose, with different limitations and requirements. Adding more and more installation methods was clearly out of the question. What we needed to do instead was to push the reset button.
Old installation methods: pre-bundle size optimization era
To better understand the changes we made to the installation methods, we first need to understand the issues they resolved.
To keep things concise, let’s focus on one of the most powerful and flexible installation methods we had before. We used TypeScript to transpile our source .ts
files into .js
files. The resulting JavaScript preserved the original project structure and retained all imports, with only TypeScript-specific syntax removed. However, since the published code still included imports for .svg
files and PostCSS-based .css
files, most build tools couldn’t handle it out of the box.
As a result, users had to individually configure their bundlers to handle these assets using CKEditor-specific plugins. Because initially we only supported webpack and later Vite, those using other bundlers had to either switch or maintain a separate CKEditor build process in parallel.
In addition to that, this method required installing dozens of npm packages, so it often led to version misalignment. To prevent that, we had a runtime check that throws an error when this happens. This turned out to be the most frequently encountered error, according to our analytics.
We could do better than that.
New installation methods
In 2023, CKEditor, the JavaScript ecosystem, and its tooling were more mature, and we had many options and common best practices that we could follow. We decided not to reinvent the wheel and remove as much “magic” as possible.
We started by rethinking the way we bundle CKEditor 5. The first step was simple – let’s pre-bundle each CKEditor 5 package so it ships one plain .js
file and one plain .css
file. That way, developers would be able to drop them into any setup without extra configuration or CKEditor-specific plugins.
This method would have worked well for npm but wouldn’t scale well on CDN, as it would require downloading dozens of .js
files and a few dozen more .css
files – a problem not entirely resolved by HTTP/2, contrary to common belief. This also didn’t solve the issue of duplicated modules caused by version mismatch between packages, which so many of our users struggled with when first installing or upgrading the editor.
To fix this, we decided to introduce two meta-packages:
-
ckeditor5
which contains the editor core and all open-source packages, -
ckeditor5-premium-features
which contains all commercial packages.
These two packages simply re-export everything from the other 80+ packages:
export * from '@ckeditor/ckeditor5-alignment';
export * from '@ckeditor/ckeditor5-autoformat';
export * from '@ckeditor/ckeditor5-autosave';
// ...
They also include bundled CSS and translation files, so CKEditor with open source and commercial packages, styles, and translation can be set up with no more than 6 imports:
import { /* ... */ } from 'ckeditor5';
import { /* ... */ } from 'ckeditor5-premium-features';
import 'ckeditor5/ckeditor5.css';
import 'ckeditor5-premium-features/ckeditor5-premium-features.css';
// Optional
import coreTranslations from 'ckeditor5/translations/pl.js';
import premiumTranslations from 'ckeditor5-premium-features/translations/pl.js';
The best part is that with importmaps, this installation method works almost exactly the same in npm and in the browser (without any additional build steps!), with the only difference being how styles are added.
However, this goes against the current recommendation of not using “barrel files” (or simply “files that just re-export other files”) because they affect bundler performance. When the bundler encounters such a file, it has to parse every dependency, their dependencies, and so on, regardless of whether we import code from the first or last re-exported file.
Fortunately, because we bundle each package, the bundler only has to parse about 80 files and their dependencies, so it isn’t that bad. Additionally, this simple change not only made the developer experience (DX) infinitely better but also reduced the number of duplicated module errors by half. When we fully remove the old installation methods, we expect this number to fall even further.
Self-inflicted pain: When tree-shaking meets barrel files
The keen readers among you might know where this is going. The rule of thumb is that the more precise the import path, the smaller the chance that we accidentally pull something that we don’t need or that doesn’t get properly removed by the bundler. The process of removing unused code is generally referred to as “dead code elimination,” but in the JavaScript ecosystem, it’s called “tree-shaking.”
In the old installation method, the code was imported from individual packages, so if we needed the Editor
class, we could import it from the @ckeditor/ckeditor5-core
index file. If we really cared about the bundler and runtime performance, we could directly import it from the @ckeditor/ckeditor5-core/src/editor/editor.js
file. This way, the bundler would not have to parse and tree-shake code from the other ~20 files that the @ckeditor/ckeditor5-core
index file re-exported.
However, in the new installation method, we did the opposite. We had just two entry points, which re-export code from 60+ and 20+ packages. This means that whenever we import something from them, the bundler has to parse and remove unused code from up to 80+ packages.
But what if we have code that bundlers can’t tree-shake? While tree-shaking relies on ES2015 module syntax and modern bundlers support tree-shaking (thanks to Rollup, which popularized it), not all code is created equal. Even though ES modules enable effective tree-shaking, any code with side effects will resist removal, no matter how good your bundler is. And with 80+ packages to analyze, the chances of hitting non-tree-shakable code multiply dramatically.
Fast forward to May 2024, and we were finishing implementing the new installation methods. The time had come to start testing the implementation in various conditions. One of the tests involved creating two simple setups that imported the same editor code but differed in the installation methods used. The new implementation worked correctly, but the bundle size was… bad. If I recall correctly, the bundle for new installation methods was 30% bigger than the original. We needed to fix this before going live with the change.
Mastering tree-shaking: From dead code to optimized bundles
In an ideal world, bundlers would remove all unused code, but they can’t always tell if the given code is safe to drop. To prevent accidentally breaking your application, they keep it in the bundle. Tree-shaking removes dead code, but it needs help to identify what’s safe to remove.
Let’s use the following code as an example:
function add( a, b ) {
return a + b;
}
add( 1, 2 );
Although we called the add
function, it doesn’t do anything else besides adding two numbers together and returning the result. It doesn’t modify any external state, like global variables, DOM, or console. This means that it doesn’t have side effects, so bundlers will remove this code.
But what happens when we add a console.log
call?
function add( a, b ) {
return a + b;
}
console.log( add( 1, 2 ) );
If we run this code as-is, the result is printed to the console. Was this console.log()
added only for debugging? Or maybe it is crucial for the application?
The bundler can’t answer this question. What it knows is that if this code were removed, we wouldn’t get the same result. The console.log()
is a side effect, which prevents the function from being tree-shaken. And it doesn’t stop there. If add()
calls other functions internally, they also become non-tree-shakable, even if they’re not used elsewhere. A single side effect can cause entire dependency chains to be included in your final bundle.
To see how much code like this lives in our bundles, we can use “side effect imports”:
import 'ckeditor5';
import 'ckeditor5-premium-features';
If you want to test your own library using this method, it’s crucial that the file you import contains the same code that will be shipped to npm. If you process your code in any way, make sure to import the distribution file (after bundling, transformation, etc.), not the source file. We explain the reason for this later in the article.
After building the project with just the above-mentioned imports using Vite, we get 297 KiB of gzipped and minified JavaScript. That’s a lot considering that this code doesn’t do anything. It’s even worse in esbuild and webpack, which produce bundles weighing 336 KiB and 325 KiB, respectively.
Bundler | Size before optimizations |
Vite | 297 KiB |
esbuild | 336 KiB |
webpack | 325 KiB |
Let’s take a closer look at what’s inside them. Because we now live in the future, we can use Sonda to analyze all bundles, which I created after having to deal with half a dozen tools to investigate this issue at the time.
Let’s add it to our Vite config and see what’s inside the bundle:
import { defineConfig } from 'vite';
import Sonda from 'sonda/vite';
export default defineConfig( {
build: {
sourcemap: true
},
plugins: [
Sonda( {
deep: true,
gzip: true,
sources: true
} )
]
} );
After building the project again, this is the report we got back from Sonda.
node_modules
).That’s a lot of squares, but we can see the biggest offenders right away. Of the 297 KiB, 231 KiB comes from our own packages, while the rest comes from external dependencies. We can click on each square to see how this file was imported and what code from it ended up in the bundle.
Making code “pure”: The art of side-effect-free JavaScript
When looking at the highlighted code (which indicates code used in the bundle), the easiest to spot and fix were variables registered in global scope:
const toPx = toUnit( 'px' );
Depending on the case, we either moved them from the global scope to functions where they were used or inlined the function calls. However, not everything can be refactored like this for one reason or another.
For example, we noticed that many classes don’t properly tree-shake because they use mixins, and we couldn’t change them without introducing breaking changes:
export default class InlineEditor extends ElementApiMixin( Editor ) {
// ...
}
Because bundlers can’t tell if ElementApiMixin( Editor )
call has a side effect, they do not tree-shake it. However, we, as the developers, do know.
To let the bundlers know too, we can use a magic /* #__PURE__ */
comment which all bundlers understand:
export default class InlineEditor extends /* #__PURE__ */ ElementApiMixin( Editor ) {
// ...
}
This comment lets the bundler know that this function call is free of side effects, so that if the InlineEditor
class is not used, it can be safely removed from the bundle.
Sporadically, we use nested mixins, so we must add this comment before all function calls:
export default class Document extends /* #__PURE__ */ BubblingEmitterMixin( /* #__PURE__ */ ObservableMixin() ) {
// ...
}
Sometimes a single /* #__PURE__ */
comment is not enough. For example, we have code-generated Protobuf bindings for our real-time collaboration features that create an object and later assign new properties to it.
import $protobuf from "protobufjs/minimal.js";
var $root = $protobuf.roots["default"] || ($protobuf.roots["default"] = {});
$root.AttributeOperation = /* ... */
$root.InsertOperation = /* ... */
// ...
export $root;
We know that the $root
object is only used as a whole and doesn’t have side effects, so it can be removed if it’s not directly imported. We cannot tell the bundler to ignore property assignments that happen after the object is created. However, we can wrap the entire code block in an IIFE and use the same /* #__PURE__ */
comment as before:
import $protobuf from "protobufjs/minimal.js";
export const messages = /* #__PURE__ */ ( () => {
var $root = $protobuf.roots["default"] || ($protobuf.roots["default"] = {});
$root.AttributeOperation = /* ... */
$root.InsertOperation = /* ... */
return $root;
} )();
If you are using Rollup or Vite to bundle your project, I have a small tip for you: you can enable the configuration option called experimentalLogSideEffects
, which warns about side effects in the code. Although it’s specific to Rollup and may not find code that other bundlers have trouble tree-shaking, it’s a good starting point.
Compilation targets: How ES2022 saved our bundles
While we were getting good results, some classes we expected to tree-shake properly still didn’t. While analyzing the source code didn’t give us any clear answers as to why, looking at the generated bundles made it obvious. Let’s look at the following code:
export class Test {
public static field = 123;
public static method() {
return 123;
}
}
How could bundlers struggle with such a simple class? Well, depending on the bundler and the target
used to generate the bundle, the output code can look something like this:
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
class Test {
static method() {
return 123;
}
}
__publicField(Test, "field", 123);
export {
Test
};
The static method()
didn’t change much because static class methods have been supported since es2015
. However, because the support for static class fields was only added in es2022
, the output for static field
has to be transformed to work in older runtimes. This transformed code in most output-compiler combinations is not tree-shakable.
Here are the outputs of some popular transformers when the target
is set to es2019
, which is what we used at the time:
If we change the target
to es2022
we get code like this:
export class Test {
static field = 123;
static method() {
return 123;
}
}
This is something that all (modern) bundlers will be able to handle and tree-shake properly.
Fortunately for us, we wanted to update our target anyway, so we went with es2022
instead of es2021
as previously planned. After building our test project again, we got the following results:
Bundler | Gzipped size after optimizing internal code |
Vite | 80 KiB |
esbuild | 121 KiB |
webpack | 136 KiB |
This is why we emphasized earlier that it’s important to test the distributed code, not just the source code. If we had skipped the transformation step, we would have missed this issue entirely.
sideEffects
flag
External dependencies: Leveraging the The improvements we already had were significant, so maybe we were done? Let’s look at the updated Sonda report.
Out of 80 KiB, less than 33 KiB comes from our own packages, and most of this code will always be used because it comes from the core editor components. However, 47 KiB is coming from external dependencies, which themselves have code that doesn’t tree-shake properly. This code is external to us, so we cannot do anything about it, right?
Actually, we can.
There is a property that can be added to the package.json
file called sideEffects
, which can either be a boolean or an array of paths to files that have side effects. The sideEffects
flag controls tree-shaking behavior. We avoided using it until this point because it’s often overused and can hide tree-shaking issues rather than solve them. However, if we tell bundlers that our main bundle in a given package doesn’t have side effects, then it and its dependencies will be tree-shaken if their code is not directly imported.
We added the following field to package.json
files of the few packages that have non-tree-shakable dependencies, to tell bundlers which files have side effects:
{
"sideEffects": [
"*.css",
"build/**/*.js",
"dist/translations/*.umd.js"
]
}
Now, when we build our project again, we get the following results:
Bundler | Size after all optimizations |
Vite | 33 KiB |
esbuild | 102 KiB |
webpack | 50 KiB |
Let’s also take a look at the final Sonda report. This one has far fewer squares than the original.
We can see that the only external dependency that exists in this bundle is es-toolkit
– a smaller, faster, and mostly API-compliant alternative to lodash-es
, which we migrated to. It’s there because our code that doesn’t get tree-shaken depends on it, not because of issues with the library itself. But, as we said earlier, this code will be used in all bundles using CKEditor 5, so it’s mostly fine.
Real-life results: 40% bundle size reduction
This catches us up to June 2025 (when I’m writing this article). After the above optimizations and some smaller bundler-specific optimizations we did in the meantime, we reduced the amount of non-tree-shakable code by 90%.
That number would make the title of this article even more eye-catching. However, what is more important is how these changes improve real editor setups like this one:
import {
ClassicEditor,
Essentials,
CKFinderUploadAdapter,
Autoformat,
Bold,
Italic,
BlockQuote,
CKBox,
CKFinder,
EasyImage,
Heading,
Image,
ImageCaption,
ImageStyle,
ImageToolbar,
ImageUpload,
PictureEditing,
Indent,
Link,
List,
MediaEmbed,
Paragraph,
PasteFromOffice,
Table,
TableToolbar,
TextTransformation,
CloudServices,
Mention
} from 'ckeditor5';
import { CaseChange, SlashCommand } from 'ckeditor5-premium-features';
import 'ckeditor5/ckeditor5.css';
import 'ckeditor5-premium-features/ckeditor5-premium-features.css';
ClassicEditor.create( document.querySelector( '#editor' ), {
plugins: [
Essentials,
CKFinderUploadAdapter,
Autoformat,
Bold,
Italic,
BlockQuote,
CKBox,
CKFinder,
CloudServices,
EasyImage,
Heading,
Image,
ImageCaption,
ImageStyle,
ImageToolbar,
ImageUpload,
Indent,
Link,
List,
MediaEmbed,
Paragraph,
PasteFromOffice,
PictureEditing,
Table,
TableToolbar,
TextTransformation,
Mention,
CaseChange,
SlashCommand
],
licenseKey: '<LICENSE_KEY>', // Replace this with your license key.
toolbar: {
items: [
'undo', 'redo',
'|', 'heading',
'|', 'bold', 'italic',
'|', 'link', 'uploadImage', 'insertTable', 'blockQuote', 'mediaEmbed',
'|', 'bulletedList', 'numberedList', 'outdent', 'indent', 'caseChange'
]
},
image: {
toolbar: [
'imageStyle:inline',
'imageStyle:block',
'imageStyle:side',
'|',
'toggleImageCaption',
'imageTextAlternative'
]
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells'
]
},
language: 'en'
} );
This is the size comparison of minified and gzipped bundles before and after optimization:
Bundler | Before | After | Improvement |
Vite | 425 KiB | 251 KiB | -40.9% |
esbuild | 447 KiB | 277 KiB | -38.0% |
webpack | 438 KiB | 251 KiB | -42.7% |
These are excellent results, especially considering that the code didn’t change at all from the end user’s perspective. Not to mention that over the last year, we introduced many new features and packages (that affect the “after” size), so we ship more code than we did when we began this journey.
In our documentation, we have a guide for further CKEditor bundle optimizations that require changing the imports on the user side. However, it mostly includes optimizations for translations and styles, in which we decided to prioritize the out-of-the-box developer experience over bundle size.
Regression monitoring: How we catch size increases early
Now that we’ve sorted out tree-shaking, we need to make sure that we don’t introduce regressions in the future. Bundle size optimization isn’t a one-time fix; it requires continuous monitoring. Funny enough, before implementing monitoring, we introduced regressions less than a week after the first round of optimizations. We were able to catch and fix them before the release. This only shows that regression monitoring is essential in large teams and projects.
The task of writing regression monitoring went to our awesome QA team, which already maintains a set of nightly end-to-end regression tests. The first version was simple – a script that took the two latest nightly builds and built the following code using Vite, esbuild and webpack:
import 'ckeditor5';
import 'ckeditor5-premium-features';
Then it compared the build sizes and posted a message to one of our Slack channels to let us know if there were any regressions in the latest nightly compared to the previous one.
However, we quickly realized that this was not enough. What we got was only the size difference between the builds, and every regression needed investigation of what actually caused it before we could even attempt to fix it. We needed a better solution that would tell us precisely what caused the issue.
This is where Sonda is useful again. While by default it outputs reports in HTML format, it can also generate JSON reports, which is precisely what we needed to compare the builds in detail.
Sonda( {
format: 'json',
deep: true
} )
Besides the format: 'json'
option, we also set deep: true
. This tells Sonda to read all the source maps, which allows us to not only know which package/bundle caused the regression, but which source file specifically.
After adding Sonda to the mix, the script reads the JSON reports, compares them, and posts the following message to a Slack channel:
Now whenever something changes, we know right away where the issue is and who to blame which PRs to inspect.
How to optimize your JavaScript library: A bundle size optimization checklist (TL;DR)
If you want to optimize your library too, here a summary of what was explained in the article and some additional steps you can take:
Inspect: Use the technique we showed above to check if your library tree-shakes properly. Make sure to test it using different bundlers because each of them has a different level of tree-shaking capabilities and limitations.
Make your code “pure”: As the first optimization step, check if you can change the code to make it tree-shakable without any or with minimal changes to the external APIs (unless you are okay with introducing breaking changes).
Help your bundler figure out if your code is pure: If you can’t update the code or introduce breaking changes, you can use magic
/* #__PURE__ */
comments. That will let the bundler know that a given piece of code is side-effect-free. However, make sure to only add it before the code that is truly free of side effects, because otherwise, you can break it.Update the
target
and don’t include polyfills: Most libraries declare supported Node or browser versions. If your library is processed, transpiled, or bundled, make sure to set the highest possibletarget
that your declared supported runtimes can handle. Additionally, make sure that you don’t include polyfills, as they should only be added by the bundler that generates the final bundles.Use the latest versions of the dependencies: If two or more packages have the same dependency, but in different versions, it is likely that it will be bundled multiple times in the end project. To minimize the chance of this happening, try to use the latest versions of production dependencies.
Use newer and lighter dependencies: While in our case we could only replace
lodash-es
with the more lightweightes-toolkit
, there are many other popular and widely used libraries that are not up to today’s standards. Some of them can be replaced by newer native APIs or by lighter and faster alternatives that make use of them. There is a growing list of available module replacements maintained by the e18e (“Ecosystem Performance”) initiative.Split your package into smaller, independent modules: If it makes sense for your library, you can split it into smaller, independent modules or imports. While this is the opposite of what we did in CKEditor 5, it was a deliberate and thought-through decision. In this case, we could say that you should “do as we say, not as we do.”
Explicitly declare files with side effects: Only as a last resort, consider using the
sideEffects
field inpackage.json
. However, as we said earlier, sometimes it only hides tree-shaking issues, so don’t overuse it.
Conclusion
There’s no faster code than no code. Unfortunately, tree-shaking is often an overlooked aspect in the JavaScript ecosystem. It’s not just that “smaller number is better”; it has a real-world impact on the users of our applications, who could download and run less code.
Improving tree-shaking can be challenging, but it’s an effort worth doing, especially in popular and mature libraries. Bundle size optimization includes minification, compression, tree-shaking, code splitting, and smart dependency management – all crucial for modern JavaScript applications.
Start with measurement, iterate on improvements, and always monitor for regressions. Your users will thank you for every kilobyte saved.