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.

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.

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.

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