Search code examples
typescriptnpmpackagecreate-react-appes6-modules

Factoring out part of app into its own NPM package results in larger overall app size


We are using create-react-app and Typescript within all of our projects and a slightly large module of commonly used React components has emerged. Trying to factor these out into its own NPM package (for easier maintenance and better reuse), here called PackageA, we have arrived at a situation where the overall size of a test app, here called TestApp, is larger than it was before (when the same code existed inside the code base of the same app). TestApp is a VERY rudimentary app which basically just showcases some (but not all) parts of the components in PackageA, formerly with the components inside the project itself, and now with this part removed and instead imported from privately published PackageA.

Sizes of initial JS chunks before factoring out PackageA with components from TestApp ("main" is assumed to be code from the project itself and the other chunk is believed to hold dependencies):

  • Gzipped: "main" chunk ~ 31 kB, chunk with external dependencies ~ 193 kB (from output after building the app)
  • Unzipped: "main" chunk ~ 93 kB, chunk with external dependencies ~ 653 kB (from browser)

Sizes after factoring out PackageA from TestApp:

  • Gzipped: main chunk ~ 22 kB, chunk with external dependencies ~ 215 kB (from output after building the app)
  • Unzipped: main chunk ~ 57 kB, chunk with external dependencies ~ 745 kB (from browser)

As can be seen, the overall size increases with 13 kB gzipped and 56 kB unzipped. This corresponds to an increase of ~ 6% gzipped and ~ 8% unzipped. This is not insanely much but I would still expect them to be somewhat similar.

Further information

  • The contents of PackageA is published as es6 modules to allow for tree-shaking which seem to work properly since unused parts of PackageA are not emitted into the output chunks of TestApp.
  • Minification of PackageA is done by UglifyJS prior to publishing using both --compress and --mangle options.
  • Source maps are not included in the source files of PackageA but only available separately.
  • The only packages listed under dependencies in PackageA's package.json are NOT used anywhere else in TestApp and were subsequently removed from the TestApp's package.json at the same time as they were added to PackageA's package.json. The same versions were used. All other dependencies of PackageA are listed under peerDependencies.
  • All sizes were verified after deleting the node_modules folder and running a fresh install of dependencies.
  • The contents of PackageA is exactly the same as the deleted folder from TestApp with the exception of an added index.ts page which exports the contents of the other files. This file is < 3 kB unzipped in size.

What could be possible sources of this increase in size and where could we start looking? Perhaps this increase might lie in how the code is transpiled in the package and that create-react-app is somehow able to do this more efficiently for code included in the project itself than for imported code. I know this is a tough question that might have many answers and that is hard to reply to.

This is the tsconfig.json used in the factored out PackageA:

{
  "compilerOptions": {
    "target": "es6",
    "lib": ["es6", "dom"],
    "jsx": "react-jsx",
    "module": "es6",
    "rootDir": "./src",
    "moduleResolution": "node",
    "declaration": true,
    "sourceMap": true,
    "outDir": "./build/esm",
    "inlineSources": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["./src"],
  "exclude": ["**/*.test.tsx", "**/*.test.ts", "**/*.stories.tsx"]
}

Solution

  • I understand that this questions might be highly dependant on the situation you're in but still thought I'd share my process.

    Measuring sizes

    Usually when you build, your framework outputs the sizes of the built artifacts. In the case of create-react-app, these sizes seem to be the size of the gzipped versions.

    You may also check your browser dev tools and note the sizes of the downloaded bundles which are typically NOT the gzipped sizes but the actual sizes.

    When factoring something out or doing some sort of comparison, take notice of the different sizes before the change and after the change. Given some sort of intuition as to how these sizes should change evaluate if this is the case or not.

    Analyzing the bundle

    With NextJS you may use @next/bundle-analyzer, in most other cases you may use source-map-explorer. The latter uses source maps to map bundled code to sources; if the source itself has source maps, then these sources are used, otherwise you will see references to node_modules. With source-map-explorer, it might be worth both looking into the visual representation as well as the json representation since, with a lot of sources, the visual representation might suppress the participation of a certain component or library which the json representation will not.

    With source-map-explorer you may make some valuable comparisons between different versions of an app.

    Analyzing particular cases of source code to production code

    After establishing with, for example, source-map-explorer that some source code expands more in some context that in another, you may want to research the issue further. Using source maps (either in development mode or in production mode if you have them available there), you may want to track the imprint of a certain portion of source code in the output bundle. A lot of browsers support mapping bundled code to source code (given source maps) but not all allow you to do the opposite. Firefox, however, has this feature and it allows you to, given bundled code, view the source code and vice versa. This is very useful when you're trying to track what some source code actually results in in the output.

    What about my use-case?

    Well, the following is a list of what caused the increased size in my case:

    • I imported from an index in my package and the index imported all of the exports of the package. This should not be a problem with esm modules, but without the sideEffects: false flag in package.json there were some ambiguities. Even though the unused sources from MY package were not added to the output bundle, some indirect dependencies could not be ruled out as not having side effects, so these were added to the bundle, even though the component that imported them were not even used. Adding sideEffects: false to the package.json of my package solved this and reduced the package size.

    • When keeping the components in my main project that was bundled using create-react-app, some convenience methods added by Babel in all transpiled files were removed thanks to the presence of @babel/plugin-transform-runtime plugin. This plugin removes these convenience methods, given that some Babel runtime was guaranteed to be present when running these files (from which they could be imported instead). When the same configuration was used for the factored out components, the size shrank even more.

    • Finally, at the time of this writing, the tsc compiler seem to output more verbose code than Babel (see TypeScript: tsc transpiles jsx to more verbose code than babel). When transpiling the package I first used tsc and after started using Babel, the size shrank even more.

    All in all, with the above changes, the output ended up being just 1.7 kB larger (unzipped) than before the refactoring which was a both understandable and acceptable result.