

Our Journey to Reducing Our Bundle Size by 90% with Embroider - Part I: Embroider Safe
When I joined Teamtailor in June 2024, the first major project I knew we needed to do as soon as possible was to switch our main, big Ember app to Embroider.
Although we, the two people in the Ember Focus team, worked on several other projects during that period, we had a fully optimized app with "split routes" by the end of the year. Creating chunks that the app loads dynamically "on demand" drastically reduced the main JavaScript bundle size.
I'll tell you about how we did it, the challenges we encountered on the way, and how we solved them, hoping that it helps other engineering teams put their Ember apps on "the modern track".
This is the track that ultimately leads to using Vite, the best state-of-the-art bundler tool, and we'll naturally want to go there and reap its benefits (At the time we undertook this project, the Vite adapter was in very early stages).
In this first post, we'll get up to the point of having the app built by Embroider, without any optimizations.
Later, we'll see how we enabled those optimizations and what benefits they gave us.
Using the right package manager
One of the first things I learned is that the less Ember-specific build tooling you use, the more important the choice of package manager becomes.
Not all package managers are created equal, and the Ember ecosystem embraced pnpm for being the fastest, most efficient and correct of the bunch when it comes to resolving node package dependencies.
Consequently, before I started the actual Embroider work, I set out to switch our package manager from yarn v1(!) to pnpm.
Switching to pnpm
Most of the errors I needed to fix were due to pnpm being stricter about package installs. It only installs explicit package dependencies under `node_modules` and keeps transitive dependencies (the dependencies of the project's dependencies) in a content addressable store.
However, several of our dependencies assumed that transitive dependencies are also installed in node_modules, and broke the build when this assumption proved incorrect.
One example was `ember-pickr` depending on `@simonwep/pickr`, and trying to load a CSS file from `node_modules/@simonweb/pickr`: adding `@simonwep/pickr` as a dependency fixed this.
pnpm has all of the features we used in yarn (and then some). It allows to resolve packages to a specific version, just as yarn does. In yarn, this is called "resolutions", while pnpm calls them "overrides". It also supports patching packages natively, so we could remove the `patch-package` dependency we'd needed before.
There were the usual, one-off fixes (how did `import { modifier } from 'ember-modifier/.'` ever work?`), but once those were squashed, we were ready to start the actual migration to Embroider.
Moving to Embroider Safe
Embroider Safe is Level 0 of Embroider compatibility. It means your app (or addon) works with Embroider without any optimizations. It makes sense, then, that this is the suggested first step for Embroider adoption.
Low hanging fruits: upgrading packages
Fortunately, a lot of our 3rd-party dependencies that didn't work with Embroider only needed to be upgraded to a more recent version: it just wasn't always obvious which package the error came from!
Getting owned by getOwner
One of the hardest things to figure out was why our Ember Data version at the time (4.11) threw a hard error when importing a module from Ember itself (5.1.1).
More precisely, the import was `import { getOwner } from '@ember/application'` which broke the build.
I asked for help on Discord and got lots of suggestions from nullvoxpopuli and runspired. This is a good time to point out that folks hanging out there are absolutely amazing – not only do they help, but they do so very fast, and consistently so.
After some back & forth, I patched the packages to import from `@ember/owner` instead, and we were back on track.
Merging in-repo addons
In Ember's classic build, in-repo addons are merged into the host app (this is also how v1 addons work), which means it can import anything in the host app.
V2 addons, however, don't work that way – they can't assume anything about the structure or the avaiable modules of the host app.
We had an in-repo addon and faced a decision: we could either convert it to a V2 addon and revise the pieces it used of the host app, or simply merge it into the host app.
Since we estimated the conversion to take quite some time and saw no particular value in keeping the addon separately from the main app, we opted for the merge.
Fixing a csso issue
Embroider uses csso for CSS minification for the contents defined in `app/styles/app.css`.
It had a subtle bug when rewriting `background:` rules, so we switched those to use the more specific `background-image:` to work around the problem.
That was a fun one!
Making sure translation files can still be dynamically loaded
We use `ember-intl` to translate our app to 29(!) languages.
The translations files are really big so we don't want to bundle them with the application and thus use the `publicOnly: true` configuration option.
When the app boots up, we load the appropriate translation file by requesting it from the local build:
// app/services/translations.ts
const translations = await this.server
.fetch(localePath)
.then((res) => res.json()
When Webpack builds the project, it needs to be able to discover modules so that it can make a dependency graph and bundle them. One of the ways, it can do this is via imports, as they express dependency (if "Module A" imports from "Module B", it implies a dependency).
We slightly modified the above code by adding a dynamic import before the server fetch:
// app/services/translations.ts
filePath = (await import(`/translations/${locale}.json`)).default;
This way, Webpack discovers those translation files, and we can set up a rule for it in its configuration. Since we wanted to fingerprint translation files, to make sure their filenames changes when their content does, this is the rule we added:
// ember-cli-build.js
return require('@embroider/compat').compatBuild(app, Webpack, {
packagerOptions: {
webpackConfig: {
module: {
rules: [
{
test: /(node_modules\/\.embroider\/rewritten-app\/translations\/)(.*\.json)$/,
type: 'asset/resource',
generator: {
filename: '[path][name]-[hash][ext][query]',
},
},
],
},
},
},
});
The `[hash]` portion creates a fingerprint, while the `[ext]` and `[query]` parts make sure the extension and query params are retained.
Handling other assets
When using Ember's classic build, broccoli-asset-rev takes care of fingerprinting them, typically via a configuration as follows:
// ember-cli-build.js
fingerprint: {
enabled: true,
generateAssetMap: true,
fingerprintAssetMap: true,
extensions: ['js', 'css', 'png', 'jpg', 'gif', 'svg', 'mp4', 'ogv', 'json'],
};
Fingerprinting assets (and any other treatment you choose to make to them) becomes the responsibility of the bundler.
Therefore, we added a Webpack module rule:
// ember-cli-build.js
{
test: /\.(png|jpg|gif|svg|mp4|ogv)$/i,
type: 'asset/resource',
generator: {
publicPath: EMBER_ASSETS_ROOT_PATH,
filename: 'assets/images/[path][name]-[hash][ext][query]',
},
},
Just as in the case of translation files, however, Webpack needs to know about a file to run the above rule on it.
This means we have to explicitly import those assets:
// app/components/avatar.js
import defaultAvatarSrc from './default-avatar.png';
export default class Avatar extends Component {
avatarSrc = defaultAvatarSrc;
}
With those changes, we were ready to ship Embroider Safe!
Fixing an issue
We crossed our fingers, deployed the Embroider build to production, and check that everything worked.
However, we didn't went far from our laptops and we were right in doing so: the first error reports came in that customers couldn't use the app as it broke during boot.
We reverted the changes, and inspected what went wrong.
It turned out that for some customers, the locale key contained an uppercase country or region code, like `en-GB`, or `fr-CA`. However, the translation files were all lowercased, so for these customers, the dynamic import didn' work, breaking the app.
The fix was as easy as lowercasing the module key:
// app/services/translations.ts
filePath = (await import(`/translations/${locale.toLowerCase()}.json`)).default;
Once we deployed the fix, the bug reports stopped coming in, and after a few days, we declared we were on Embroider!