The unexpected impact of dynamic imports on tree shaking

Christian Gonzalez
5 min readSep 17, 2019

--

We learned a lot from a recent investigation of a bundle size regression in one of the Office web applications. Here is a summary of our investigation in the hope that it will help others in their pursuit of improved application performance.

As part of a recent performance push, we decided to dive deeper into the dependency graph generated by webpack-bundle-analyzer to make sure that all the code in our main bundle was indeed required on the boot path. To our surprise, we discovered that a large portion of our bundle was taken up by an internal package @ms/office-web-platform. This package contains a lot of the common infrastructure shared by various Office web experiences, such as logging, authentication, and common utilities. Over time, this package has become quite large, but often times only a small percentage of the code in the package is on the critical boot path of an application. Upon closer inspection, it appeared as if tree shaking was not enabled at all for the package.

We first expected this to be a configuration issue, so we verified that @ms/office-web-platform was meeting the tree-shaking requirements. We made sure the package was exporting ECMA Script Modules, had a module entry in package.json, and properly set the sideEffects flag in package.json. We also confirmed that another application using @ms/office-web-platform was seeing the correct tree shaking behavior.

We then scanned the modules in our main bundle to find all the static imports of @ms/office-web-platform. We only found usages of the logger, with imports of the form:

import { logger } from '@ms/office-web-platform';

This logging module is very small and does not have dependencies on many other modules. The @ms/office-web-platform package has a structure that is roughly like this:

/lib
/authentication
auth.js
index.js
/telemetry
index.js
logger.js
...
index.js

All index.js files simply re-export other modules. As an exercise, we tried changing the path of the imports from the root index.js file to instead be pointing at the /telemetry/index.js module. Our imports then look like this:

import { logger } from '@ms/office-web-platform/lib/telemetry';

This change dramatically reduced the bundle size, which was great! However, this change would force us to take a dependency on the internal package structure of the @ms/office-web-platform package and there was a concern that this bundle would easily regress the next time some adds an import to something from the @ms/office-web-platform. We needed to understand what was actually going on here.

The webpack stats files weren’t providing us with any clues, so we went ahead and started removing imports and eventually got to a state where were able to get a minimal reproduction of the issue by removing all app code except for this:

import { logger } from '@ms/office-web-platform';
import { getAuthService } from './AuthService';
export function boot() {
logger.loadFull();
return getAuthService();
}

Removing either import in this case cause the bundle size to dramatically drop. We eventually went into getAuthServiceand started removing code until we saw the desired bundle output. We tracked the issue to this line of code ingetAuthService:

// IMPORTANT: Dynamically load dependency on office-web-platform  
// package to ensure that it is not included inside initial app
// bundle. This method is not called on app boot up so delay loading
// makes sense.
const platform = await import('@ms/office-web-platform');

This code had good intentions, it meant to delay load some code that isn’t needed on the initial render. When this line of code was first added we didn’t have any telemetry in the boot slice, so we didn’t have any code from @ms/office-web-platform in the main chunk. When we added logging to the main slice, we introduced a static import of the index.js module at the root of the @ms/office-web-platform. A static require isn't a problem since webpack tree shaking will only include the bits we care about, which we were able to confirm by removing the dynamic import. The problem comes with the dynamic import of that same index.js module.

import('@ms/office-web-platform');

When you dynamically import a module, it automatically makes that module ineligible for tree shaking. The result of the dynamic import is an object with all the exports of the module. Due to the dynamic nature of JavaScript, webpack can’t easily determine which exports will be used, so webpack can’t do any tree shaking. For more details on tree shaking behavior for dynamic imports, checkout this great description from the webpack team.

This discovery revealed why we weren’t tree shaking the @ms/office-web-platform module, but it was still unclear why all of the code from that package was in the main slice rather than just the telemetry module we were statically importing. The spec for ECMA script modules defines that modules that can only be evaluated once, so we cannot evaluate the index.js module once in the boot slice and then once again later in the delay loaded slice. Since we have a static import @ms/office-web-platform/lib/index.js module in the boot path, we expect the evaluation of the index.js module to happen in the boot chunk. Since this exact index.js module is also required in a dynamic import, this module is no longer eligible for tree shaking, meaning we will pull in everything that is re-exported by that module in the boot slice!

The fix here was to switch all the dynamic imports of @ms/office-web-platform to be scoped to specific modules that were being required, meaning we would update our imports from import('@ms/office-web-platform') to import('@ms/office-web-platform/lib/expensiveModule')This has the downside of making the application dependent on the internal package structure of @ms/office-web-platform, meaning we would run into issues if the logger.js module was moved to different location in the package. In the end we felt this trade off worth making to improve our boot performance. Going forward, we are going to evaluate all our dynamic imports and ensure we are not pulling in too much code. We’re also considering splitting larger packages like @ms/office-web-platform into smaller modules so that we can go back to importing from the index.js file at the root of the module.

Edit August 2020:

Since publishing this article, we recently moved to a different pattern for dynamic imports that does not cause dependencies on the internal package structure of an external package. Rather than having a reference import('@ms/office-web-platform/lib/expensiveModule') inside of our package, we create a module in the package consuming @ms/office-web-platform that has a static import to the expensive modules we need and dynamically import that module. For example, we would create a file officeWebPlatformLazy.js inside the package that wishes to delay load @ms/office-web-platform. The contents of officeWebPlatformLazy.js would look something like this:

export{ expensiveModule } from '@ms/office-web-platform';

Now, we replace all the import('@ms/office-web-platform/lib/expensiveModule') statements with statements like import('../officeWebPlatformLazy'). With this approach, we still get the expected bundle output, but removed the dependency on the internal structure of the external package @ms/office-web-platform.

--

--