Skip to main content

Bundler-friendly code

  • New
  • Explanation
  • 6-min read

This guide lists coding patterns that result in oversized bundles. While the focus is on app functions running on the serverless platform, where size has the most impact, the guidance applies to JavaScript and TypeScript apps in general.

Understand your environment​

Your source files go through transpilation and bundling to become a shippable JavaScript function.

This process has some undisputable facts:

  1. It's still JavaScript.
  2. There is no dead code elimination — the code you write is the code you ship and execute. Write more, execute more.
  3. Tools that reduce your application's code footprint use static code analysis. Static code analysis can't reason about your code and therefore takes shortcuts.

In environments like Node.js or the browser, this usually isn't a problem. The Dynatrace JavaScript runtime, however, is different:

  • Low CPU—parsing and executing take longer.
  • Low memory—the more code you write, the more memory is occupied during execution.
  • One-off execution—everything is discarded after each invocation and starts fresh. Startup costs that seem negligible in Node.js or browser apps are full execution costs here. Dead code or unintended side effects are parsed and potentially executed with every single invocation.

Therefore, writing efficient JavaScript is paramount for application functions, especially on Kappa (the Dynatrace serverless runtime).

Use tree-shaking to reduce bundle size​

Tree-shaking is static code analysis that follows your dependency graph and checks for the usage of exported and imported symbols: const and let bindings, functions, and classes.

To use tree-shaking effectively, only rely on ECMAScript module dependencies. Use Bundlephobia to check whether a library supports tree-shaking. For example, lodash is not tree-shakeable, but the ECMAScript module alternative lodash-es is tree-shakeable and side-effect-free, meaning you only include what you actually use. Side effects are covered later in this guide.

But even if everything you use is built on ES Modules and is tree-shakeable, there are still many ways to inadvertently increase your bundle size.

Enforce with ESLint​

Add a package denylist to your eslint.config.mjs to catch known non-tree-shakeable imports at lint time:

eslint.config.mjs
{
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "lodash",
message: "'lodash' is not tree-shakeable. Use 'lodash-es' instead.",
},
{
name: "moment",
message: "'moment' is not tree-shakeable. Use 'date-fns' or 'dayjs' instead.",
},
],
},
],
},
}
Note

If you already have a no-restricted-imports rule in your config, don't replace it — append these entries to your existing paths array.

The no-restricted-imports denylist covers known offenders. For any dependency outside the list, check Bundlephobia to verify tree-shakeability before adding it.

To also ban CommonJS require() and AMD define(), add no-restricted-syntax selectors. See the combined snippet below and combine all selectors into one rule entry, since ESLint doesn't merge multiple entries for the same rule.

React Doctor

React Doctor complements the denylist with rules that catch a wider class of non-tree-shakeable patterns across your codebase:

  • no-barrel-import—flags imports from any barrel file in your own code.
  • no-full-lodash-import—flags full-library lodash imports.
  • no-moment—flags moment usage and suggests lighter alternatives.

Use it alongside ESLint to surface dependency issues that a fixed denylist can't enumerate.

TypeScript: Avoid code-emitting TypeScript​

The best TypeScript is the one that can just be erased to regular JavaScript, but some features create code that is different from what you'd usually expect. Let's take enums, for example.

enum Result {
Ok = 'Ok',
Error = 'Error',
}

Although this looks like an innocent way to define state, it generates the following JavaScript, which tree-shaking can't analyse:

var Result;

(function (Result) {
// Immediately Invoked Function Expression (IIFE) — opaque to tree-shaking
Result['Ok'] = 'Ok';
Result['Error'] = 'Error';
// All variants loaded, even if you only use one
})(Result || (Result = {}));

The IIFE pattern means tree-shaking can't remove unused variants—use one, load them all. For Result, that's two values. For real-world enums that span hundreds of lines, it adds up fast.

Enums also introduce side effects that affect the type system. Use string union types instead, as they provide the same type safety.

But it's not only enums, there are other elements in TypeScript that generate code. Activate the erasableSyntaxOnly flag (available since TypeScript 5.8) to catch these at compile time. For more information, see erasable-syntax-only.

Tip

Using const enum seems like a good fix, but it isn't. It needs full cross-file type information to inline values safely, which doesn't work with isolatedModules: true, which is required by Vite, esbuild, and SWC. Instead, use string union types.

Enforce with TypeScript​

Enable erasableSyntaxOnly in your tsconfig.json (TypeScript 5.8+) to catch enums, namespaces, and parameter properties at compile time:

tsconfig.json
{
"compilerOptions": {
"erasableSyntaxOnly": true
}
}

Enforce with ESLint​

For earlier feedback at lint time, add no-restricted-syntax selectors to your eslint.config.mjs. The TypeScript AST selectors (TSEnumDeclaration, TSModuleDeclaration, and TSParameterProperty) require @typescript-eslint/parser as your ESLint parser.

If you're also using the no-restricted-syntax selectors from the tree-shaking section above—require() and define()—combine all selectors into a single rule entry. ESLint doesn't merge multiple entries for the same rule:

eslint.config.mjs
{
rules: {
"no-restricted-syntax": [
"error",
{
selector: "TSEnumDeclaration",
message: "Avoid TypeScript enums — use a string union type instead.",
},
{
selector: "TSModuleDeclaration[kind='namespace']",
message: "Avoid TypeScript namespaces — use a plain ES module instead.",
},
{
selector: "TSParameterProperty",
message: "Avoid TypeScript parameter properties — assign fields explicitly in the constructor body.",
},
{
selector: "CallExpression[callee.name='require']",
message: "Use ESM import statements instead of CommonJS require().",
},
{
selector: "CallExpression[callee.name='define']",
message: "Use ESM import statements instead of AMD define().",
},
],
},
}

TypeScript: Import types when you only need types​

TypeScript works with two namespaces:

  • The type namespace—every type or interface you create adds a name to this namespace.
  • The value namespace—you add a name to this namespace for every const, let, function.

They are distinct, meaning you can use the same name in both namespaces and won't clash. They have a one-way boundary, which means that you can access names in the value namespace and add information to the type namespace using the typeof operator, but not the other way around. There is no way to get type information into your JavaScript runtime code.

Everything in the type namespace is erasable. Code in the value namespace might be transpiled.

A few elements contribute the same name to both namespaces: enums, classes, and namespaces. Even if you set out not to use them in your code, you might still have some legacy code you constantly refer to, even if you only need type information. You can tell TypeScript that you're only interested in the type information by using:

import type { Foo } from 'foo';

With that, TypeScript will include the type information, but the bundler won't load any runtime code.

Enforce with ESLint​

The @typescript-eslint/consistent-type-imports rule enforces import type automatically and supports auto-fix:

eslint.config.mjs
{
rules: {
"@typescript-eslint/consistent-type-imports": "error",
},
}

Run eslint --fix to convert existing value imports that only use types to import type in one pass.

ES Modules: Avoid classes when not necessary​

Classes are not tree-shakeable. If you use a class, even if only for a single method, you'll end up with the entire class in your file. Sometimes, you don't need classes at all. The following code is inspired by a real shared module that is imported by many packages:

// Environment.ts
export default class Environment {
private static variableList: string[] = [];
static variables(): string[] {
/* ... */
}
static setVariable(key: string, value: any): void {
/* ... */
}
static getValue(key: string): unknown {
/* ... */
}
}

// Usage in another file
import * as Environment from './Environment';
console.log(Environment.variables());

Even if you only use a single method, you include the entire Environment class. A similar module with functions does the same thing, but is tree-shakeable:

const variableList: string[] = [];

export function variables(): string[] {
/* ... */
}
export function setVariable(key: string, value: any): void {
/* ... */
}
export function getValue(key: string): unknown {
/* ... */
}

// Usage in another file
import * as Environment from './Environment';
console.log(Environment.variables());

A simple template literal can replace a full DQL builder class. If you use a DQL builder even once, you include the whole class. Replacing it with a template literal eliminates 12–15 KB — around 10% of total function size in at least one real case. That's significant in serverless execution.

const query = `fetch dt.davis.events, from: ${from}, to: ${to}
| filter contains(affected_entity_ids, "${monitorId}")`;

ES Modules: Be aware of side effects​

Here's a real example of an accidental side effect. Consider this function:

import { identifier } from './dependency.ts';

export default function main() {
return identifier;
}

It just loads a string from a dependency. The dependency looks like this:

import z from 'zod';

export const identifier = 'yolo';

const davisValueSchema = z.object({
name: z.string(),
values: z.number().array(),
});

type DavisValue = z.infer<typeof davisValueSchema>;

export const isDavisValue = (record: unknown): record is DavisValue => davisValueSchema.safeParse(record).success;

Tree-shaking should only include the string from line 2, but it will actually include all of Zod. The davisValueSchema on line 4 executes within the module scope, making it a side effect. Tree-shaking can't tell whether that schema creation matters for your app's functionality — it only knows the code runs at module load time, so it pulls in everything required to run it.

Tip

Splitting code into separate, focused files solves this. Keep your modules small and side-effect-free.

Still have questions?
Find answers in the Dynatrace Community