Web Loom logo
Plugin BookDynamic Loading and Module Resolution Strategies
Part II: Implementation and Architecture

Dynamic Loading and Module Resolution Strategies

Explore techniques for loading plugins at runtime and resolving dependencies.

Chapter 6: Dynamic Loading and Module Resolution Strategies

Chapter 5 ended at the moment the registry calls load(). That call is the handoff from coordination to execution — from knowing what a plugin is to actually running its code. This chapter covers everything that happens in that moment and everything that shapes it: how plugin code is fetched, which module format it arrives in, how its assets are isolated from the host, and what happens when any of it goes wrong.

Dynamic loading is what makes a plugin system genuinely extensible at runtime. Without it, plugins are just build-time dependencies with extra ceremony. With it, a user can install a plugin today and have it running without touching the application's source code or triggering a redeploy. That capability comes with complexity — different module formats, cross-origin constraints, dependency sharing, and failure modes that do not exist in static imports — and managing that complexity is the subject of this chapter.


6.1 Module Loading Approaches

The loader's first task is always the same: turn a URL or file path from the manifest's entry field into a module object with callable exports. How it does that depends on the environment it is running in and the format the plugin was compiled to.

How Production Systems Load Plugins

The range of approaches in production systems tracks closely with how much the system controls its execution environment.

VS Code has the most complex loader because it supports extensions in three distinct environments: a local Node.js extension host, a browser-based web worker, and a remote server. Each environment can run different module formats, so the loader detects the environment before deciding how to load:

async function loadExtension(manifest: IExtensionManifest) {
  const isWeb = typeof window !== 'undefined';
  const loader = isWeb ? loadESM : loadCommonJS;
  return loader(manifest.main);
}
 
async function loadESM(url: string) {
  return import(url);
}
 
async function loadCommonJS(modulePath: string) {
  return require(modulePath);
}

Vite takes advantage of the fact that modern browsers support ES modules natively, eliminating any need for a bundler in development. In development the plugin is served as raw source files; in production it is pre-bundled with Rollup:

// Development: direct ESM import, no bundling
const plugin = await import('/plugins/my-plugin/index.js');
 
// Production: pre-bundled and hashed
const plugin = await import('/assets/my-plugin-abc123.js');

Babel's loader is the simplest of all, because it runs at build time in a trusted Node.js environment. Plugins are loaded synchronously via require — there are no network requests, no async concerns, and no untrusted code:

function loadPlugin(name: string) {
  const resolved = resolvePlugin(name);
  const plugin = require(resolved);
  return validatePlugin(plugin);
}

Backstage treats plugins as regular npm packages. They are imported like any other dependency, either statically at build time or dynamically for code splitting. This is the simplest possible mental model — plugin authors do not need to learn a new packaging system:

// Static import — bundled together
import { catalogPlugin } from '@backstage/plugin-catalog';
 
// Dynamic import — separate chunk, loaded on demand
const plugin = await import('@backstage/plugin-techdocs');

Module Formats and the Dual Package Hazard

Modern JavaScript has two official module formats — ES Modules (ESM) and CommonJS (CJS) — and they do not interoperate cleanly in Node.js. The most dangerous manifestation of this is the dual package hazard: if the same plugin package is loaded as both ESM and CJS, you get two separate instances of it. Singletons break, instanceof checks fail, and the bugs are extremely hard to trace:

// Two instances of the same plugin — a subtle disaster
import plugin1 from 'my-plugin';       // ESM path
const plugin2 = require('my-plugin');  // CJS path
console.log(plugin1 === plugin2);      // false!

The solution is package.json conditional exports, which lets a package declare separate entry points for ESM and CJS consumers while ensuring Node.js only resolves one of them per process:

{
  "name": "my-plugin",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  }
}

For a plugin host that needs to load modules in any format from any source, a universal loader wraps the format-specific paths behind a common interface:

class UniversalModuleLoader {
  async load<T>(path: string, options: LoadOptions = {}): Promise<T> {
    const { format = 'auto', timeout = 30000 } = options;
 
    const detectedFormat = format === 'auto'
      ? await this.detectFormat(path)
      : format;
 
    return withTimeout(async () => {
      switch (detectedFormat) {
        case 'esm':    return this.loadESM<T>(path);
        case 'cjs':    return this.loadCommonJS<T>(path);
        case 'umd':    return this.loadUMD<T>(path);
        case 'systemjs': return this.loadSystemJS<T>(path);
        default:
          throw new Error(`Unknown module format: ${detectedFormat}`);
      }
    }, timeout);
  }
 
  private async loadESM<T>(url: string): Promise<T> {
    const module = await import(/* @vite-ignore */ url);
    return module.default || module;
  }
 
  private loadCommonJS<T>(path: string): T {
    if (typeof require === 'undefined') {
      throw new Error('CommonJS not supported in this environment');
    }
    delete require.cache[require.resolve(path)];
    return require(path);
  }
 
  private async loadUMD<T>(url: string): Promise<T> {
    return new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = url;
      script.onload = () => resolve(this.extractUMDExport(url) as T);
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
 
  private async detectFormat(path: string): Promise<ModuleFormat> {
    if (path.endsWith('.mjs')) return 'esm';
    if (path.endsWith('.cjs')) return 'cjs';
    const pkgJson = await this.findPackageJson(path);
    if (pkgJson?.type === 'module') return 'esm';
    return 'cjs';
  }
}

Dependency Sharing with Module Federation

The dual package hazard becomes critical when plugins share framework dependencies with the host. If a plugin bundles its own copy of React and the host also has React, two React instances exist simultaneously — hooks break, context does not cross component boundaries, and the errors are deeply confusing.

Webpack Module Federation solves this by allowing the host to declare which dependencies are shared, and allowing plugins to consume the host's version rather than bundling their own:

// Host webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};
 
// Plugin webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'myPlugin',
      filename: 'remoteEntry.js',
      exposes: {
        './Plugin': './src/Plugin',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
      },
    }),
  ],
};
 
// Host loads the plugin at runtime
const container = await import('https://plugin.example.com/remoteEntry.js');
await container.init(__webpack_share_scopes__.default);
const Plugin = await container.get('./Plugin');

The singleton: true flag is the critical part — it tells Module Federation to use only one copy across the entire application, regardless of which version each party declared. Without it, version mismatches silently fall back to loading two copies.

SystemJS is an alternative for environments where you do not control the build toolchain. It is a universal module loader that handles ESM, CJS, AMD, and UMD at runtime, and supports import maps for redirecting bare specifiers:

import SystemJS from 'systemjs';
 
SystemJS.addImportMap({
  imports: {
    'react': 'https://esm.sh/react@18',
    'react-dom': 'https://esm.sh/react-dom@18',
  },
});
 
const plugin = await SystemJS.import('https://cdn.example.com/plugin.js');

SystemJS adds roughly 15KB to the host bundle — worth considering when evaluating whether its flexibility justifies the weight.

Isolating Heavy Work in Web Workers

Some plugins perform computation that should not run on the main thread — data processing, encryption, format parsing. VS Code's extension host is effectively a dedicated Web Worker for extensions, keeping extension execution entirely separate from the editor's rendering. The same pattern applies at the individual plugin level:

// Main thread — plugin sends work to its own worker
class DataProcessingPlugin {
  private worker: Worker;
 
  async init() {
    this.worker = new Worker(
      new URL('./dataProcessor.worker.js', import.meta.url),
      { type: 'module' }
    );
  }
 
  async processLargeDataset(data: unknown[]) {
    return new Promise((resolve, reject) => {
      this.worker.postMessage({ type: 'process', data });
      this.worker.onmessage = (e) => {
        if (e.data.type === 'result') resolve(e.data.result);
        else if (e.data.type === 'error') reject(new Error(e.data.error));
      };
    });
  }
}

Plugin workers are particularly useful for plugins that process large files, run machine learning models, or maintain long-running background jobs. The main thread stays responsive; the worker handles the cost.


6.2 Bundle Splitting and Code Splitting

Dynamic loading only delivers its performance promise when the host's own bundle does not already contain the plugin code. The key constraint is that each plugin must be its own chunk — a separately deliverable unit of JavaScript that the browser downloads and executes only when the plugin is activated.

Keeping Plugins Out of the Host Bundle

The standard mechanism is a dynamic import() with a build-tool comment that suppresses bundling:

// The webpackIgnore/vite-ignore comment tells the bundler not to inline this
const pluginModule = await import(
  /* webpackIgnore: true */
  /* @vite-ignore */
  pluginManifest.entry
);

Without these comments, Webpack and Vite will attempt to statically analyse the import and include the referenced module in the host bundle. The comment signals that the URL is dynamic and should be left as a runtime import().

For plugins that are part of the same monorepo or npm workspace and whose source is known at build time, named chunks give the bundler enough information to split them correctly while still allowing dynamic loading:

const module = await import(
  /* webpackChunkName: "[request]" */
  /* webpackPrefetch: true */
  manifest.entry
);

The webpackChunkName: "[request]" pattern names the chunk after the module being imported, making bundle analysis much easier. Chunks named my-plugin.js are far more useful in a bundle analyser than chunks named chunk-abc123.js.

The Lazy Plugin Loader

The loader that integrates with the registry tracks which plugins have already been loaded so that navigating to a plugin's route twice does not trigger two network requests:

class LazyPluginLoader {
  private loadedModules = new Map<string, PluginModule>();
 
  async loadOnDemand(pluginId: string): Promise<PluginModule> {
    if (this.loadedModules.has(pluginId)) {
      return this.loadedModules.get(pluginId)!;
    }
 
    const manifest = this.registry.get(pluginId)?.manifest;
    if (!manifest) throw new Error(`Plugin ${pluginId} not registered`);
 
    const module = await import(
      /* webpackIgnore: true */
      manifest.entry
    );
 
    this.loadedModules.set(pluginId, module);
    return module;
  }
 
  prefetch(pluginIds: string[]) {
    pluginIds.forEach(id => {
      const manifest = this.registry.get(id)?.manifest;
      if (!manifest) return;
 
      const link = document.createElement('link');
      link.rel = 'prefetch';
      link.href = manifest.entry;
      document.head.appendChild(link);
    });
  }
}

Prefetch vs Preload

The <link rel="prefetch"> and <link rel="preload"> hints give the browser guidance about which resources to fetch ahead of when they are explicitly needed, but they have meaningfully different semantics.

prefetch is a low-priority background hint. The browser fetches the resource during idle time, after higher-priority work is done. It is appropriate for plugins that are likely to be needed soon — the analytics-informed preloading from chapter 5 should use prefetch so it never competes with in-progress user interactions.

preload is a high-priority instruction. The browser fetches it immediately, in parallel with the current page load. It is appropriate for plugins that will definitely be needed as part of the current navigation — a plugin that owns the current route, for example.

function hintPluginLoad(href: string, priority: 'prefetch' | 'preload') {
  const link = document.createElement('link');
  link.rel = priority;
  link.as = 'script';
  link.href = href;
  document.head.appendChild(link);
}
 
// Likely needed soon — background fetch
hintPluginLoad(manifest.entry, 'prefetch');
 
// Needed now — fetch immediately
hintPluginLoad(manifest.entry, 'preload');

Overusing preload defeats its purpose — the browser deprioritises preload hints when there are too many of them competing. Reserve it for plugins that are unambiguously required for the current page render.


6.3 Asset Management

A plugin is rarely just JavaScript. It brings CSS, icons, images, localisation files, and potentially fonts. Each of these assets needs to be loaded without interfering with the host's own assets or with other plugins' assets.

CSS Isolation

The most reliable approach to CSS isolation in the browser is Shadow DOM. A plugin component that attaches its shadow root carries its styles inside the shadow boundary — they cannot leak out, and the host's styles cannot leak in:

class IsolatedPluginWidget extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'closed' });
 
    // Styles are scoped to this shadow root
    const style = document.createElement('style');
    style.textContent = `
      .widget { background: var(--plugin-bg, white); }
      .title { font-size: 1.2rem; }
    `;
 
    shadow.appendChild(style);
    shadow.appendChild(this.render());
  }
}

The var(--plugin-bg, white) pattern is important — CSS custom properties (variables) do cross the shadow boundary, which is the intended mechanism for theme integration. The host sets --plugin-bg on a parent element; the plugin reads it through its shadow root. Isolation is preserved for regular properties; design tokens flow through intentionally.

When Shadow DOM is not practical — for plugins that render inside React's VDOM, for example — CSS Modules at build time achieve class-name isolation without runtime overhead:

// Plugin uses CSS Modules (class names are hashed at build time)
import styles from './widget.module.css';
 
function MyWidget() {
  return <div className={styles.container}>...</div>;
}
// Renders as: <div class="widget_container_x7k2p">...</div>

The hashed class name prevents collisions between plugins that happen to use the same class name. No runtime isolation mechanism is needed because the classes are made unique at build time.

Dynamic Style Injection

For plugins loaded at runtime, CSS cannot be bundled into the host's stylesheet. It needs to be injected dynamically when the plugin activates and removed when it deactivates:

class PluginStyleManager {
  private injectedSheets = new Map<string, HTMLLinkElement>();
 
  inject(pluginId: string, cssUrl: string): void {
    if (this.injectedSheets.has(pluginId)) return;
 
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = cssUrl;
    link.dataset.pluginId = pluginId;
    document.head.appendChild(link);
 
    this.injectedSheets.set(pluginId, link);
  }
 
  remove(pluginId: string): void {
    const link = this.injectedSheets.get(pluginId);
    if (link) {
      link.remove();
      this.injectedSheets.delete(pluginId);
    }
  }
}

Calling remove() during the plugin's unmount lifecycle hook ensures styles do not accumulate in the document as plugins are activated and deactivated over the course of a session.

Static Assets

Icons, images, and other static assets should be referenced through versioned, content-hashed URLs. This allows aggressive caching — once a user has fetched the asset, they will never need to refetch it for that version of the plugin — while guaranteeing that updates are reflected immediately when the plugin version changes:

{
  "id": "my-plugin",
  "version": "1.2.0",
  "icon": "https://cdn.example.com/plugins/my-plugin@1.2.0/icon.svg",
  "entry": "https://cdn.example.com/plugins/my-plugin@1.2.0/index.js"
}

The host should apply Cache-Control: public, max-age=31536000, immutable to versioned plugin assets. The immutable directive tells the browser it does not need to revalidate, even after the max-age would normally expire — because the URL itself changes when the content changes.


6.4 Development vs Production Loading

The loading strategy appropriate for production — minimised bundles, content-hashed filenames, CDN delivery — is actively harmful during development. Iterating on a plugin should be fast: change a file, see the result within 200ms, without rebuilding the world.

Development Workflows

Vite's development server serves source files over native ESM with no bundling step. Changes are propagated via WebSocket and the browser re-imports only the changed module — not the whole application:

// vite.config.ts
export default {
  plugins: [
    {
      name: 'plugin-hmr',
      handleHotUpdate({ file, server }) {
        if (file.includes('/plugins/')) {
          server.ws.send({
            type: 'custom',
            event: 'plugin-update',
            data: { file },
          });
        }
      },
    },
  ],
};
 
// Client-side HMR handler
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    pluginRegistry.reload(pluginId, newModule);
  });
}

VS Code extension development uses TypeScript's incremental watch mode so that only changed files are recompiled, combined with a dedicated "Extension Development Host" window that automatically reloads the extension:

{
  "type": "extensionHost",
  "request": "launch",
  "name": "Extension Development",
  "args": ["--extensionDevelopmentPath=${workspaceFolder}"],
  "outFiles": ["${workspaceFolder}/out/**/*.js"],
  "preLaunchTask": "npm: watch"
}

Babel's development story is the simplest: @babel/register intercepts require() calls and transforms TypeScript and JSX on the fly, so plugin authors can write TypeScript and run it without a compilation step:

require('@babel/register')({
  plugins: ['transform-typescript', 'transform-jsx'],
  extensions: ['.ts', '.tsx', '.js', '.jsx'],
});
 
const plugin = require('./my-plugin.ts');

Hot Module Replacement

A full HMR implementation for plugins needs to be careful about state. A naive reload destroys all plugin state — if the plugin had an open modal or a partially filled form, the reload clears it. A state-preserving reload captures the plugin's state before disposal and restores it after the new version mounts:

class PluginHMRManager {
  private activePlugins = new Map<string, PluginInstance>();
 
  async reload(pluginId: string, newModule: PluginModule): Promise<void> {
    const existing = this.activePlugins.get(pluginId);
 
    // 1. Capture state before dispose
    const preservedState = existing?.getState?.();
 
    // 2. Dispose the old module
    await existing?.module.dispose?.();
 
    // 3. Load new module and restore state
    const instance = this.createInstance(newModule);
    if (preservedState) {
      instance.setState?.(preservedState);
    }
 
    // 4. Re-mount and update registry
    await instance.mount(this.sdk);
    this.activePlugins.set(pluginId, instance);
  }
}
 
// Plugin adds HMR support by implementing these hooks
export const myPlugin = {
  init(sdk) { /* ... */ },
 
  dispose() {
    this.cleanup();
  },
 
  getState() {
    return { counter: this.counter };
  },
 
  setState(state) {
    this.counter = state.counter;
  },
};
 
if (import.meta.hot) {
  import.meta.hot.accept();
}

The getState / setState pair is a contract between the plugin and the HMR manager. Plugins that do not implement them get a full re-initialisation on every reload, which is fine for stateless UI plugins.

Source Maps and Debugging

Development builds should use inline source maps so that TypeScript stack traces appear in the browser debugger rather than compiled JavaScript line numbers:

// vite.config.ts
export default {
  build: {
    sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : 'hidden',
  },
};

The hidden strategy for production is the right compromise: source maps are generated and stored on the error reporting server, but the deployed JavaScript files do not reference them. Users cannot access the source maps, but when a production error is reported, you can map the error's line and column back to the original TypeScript:

import { SourceMapConsumer } from 'source-map';
 
async function getOriginalPosition(file: string, line: number, column: number) {
  const mapResponse = await fetch(`${file}.map`);
  const map = await mapResponse.json();
  const consumer = await new SourceMapConsumer(map);
  return consumer.originalPositionFor({ line, column });
}

A development logger that reports load timing in real time helps plugin authors identify performance regressions as they build:

class PluginDevLogger {
  private enabled = process.env.NODE_ENV === 'development';
 
  logLoadTiming(pluginId: string, phase: string, duration: number) {
    if (!this.enabled) return;
    const indicator = duration > 1000 ? '🔴' : duration > 500 ? '🟡' : '🟢';
    console.log(`${indicator} [${pluginId}] ${phase}: ${duration}ms`);
  }
 
  logStateTransition(pluginId: string, from: string, to: string) {
    if (!this.enabled) return;
    console.log(`🔄 [${pluginId}] ${from}${to}`);
  }
 
  logError(pluginId: string, phase: string, error: Error) {
    console.group(`❌ Plugin Error: ${pluginId}`);
    console.log('Phase:', phase);
    console.error(error);
    console.groupEnd();
  }
}

Production Build Configuration

Production builds trade the fast iteration cycle for delivery efficiency. The key differences from a development build are minification, content hashing, and chunk splitting:

// Webpack production configuration
const prodConfig = {
  mode: 'production',
  devtool: 'hidden-source-map',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: { drop_console: true },
        },
      }),
    ],
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
        },
      },
    },
  },
};
 
// Vite production configuration
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'react-vendor': ['react', 'react-dom'],
          'ui-vendor': ['@mui/material', '@emotion/react'],
        },
      },
    },
    minify: 'terser',
    target: 'es2020',
  },
};

Content-hashed filenames enable indefinite caching for plugin assets. When the server sends Cache-Control: public, max-age=31536000, immutable for a file named my-plugin-a3f9c1b2.js, the browser caches it permanently — and when the plugin is updated, the hash changes, the URL changes, and the browser fetches fresh.


6.5 Error Handling and Fallbacks

Dynamic loading introduces failure modes that static imports cannot have: network errors, CORS rejections, malformed bundles, parse failures, and activation timeouts. Each needs to be caught, categorised, and handled in a way that does not silently corrupt the application state.

Categorising Load Failures

A typed error hierarchy makes it possible to handle different failure modes differently in the caller:

type PluginLoadError =
  | { kind: 'network'; url: string; status?: number; cause: Error }
  | { kind: 'parse'; url: string; cause: SyntaxError }
  | { kind: 'validation'; message: string; manifest: PluginManifest }
  | { kind: 'timeout'; url: string; timeoutMs: number }
  | { kind: 'activation'; pluginId: string; phase: string; cause: Error };
 
class PluginLoader {
  async load(manifest: PluginManifest): Promise<PluginModule> {
    let raw: unknown;
 
    try {
      raw = await withTimeout(
        () => import(/* @vite-ignore */ manifest.entry),
        30_000,
        () => ({ kind: 'timeout', url: manifest.entry, timeoutMs: 30_000 } as PluginLoadError)
      );
    } catch (cause) {
      if (cause instanceof SyntaxError) {
        throw { kind: 'parse', url: manifest.entry, cause } satisfies PluginLoadError;
      }
      throw { kind: 'network', url: manifest.entry, cause } satisfies PluginLoadError;
    }
 
    if (!isPluginModule(raw)) {
      throw {
        kind: 'validation',
        message: 'Module does not export a valid plugin',
        manifest,
      } satisfies PluginLoadError;
    }
 
    return raw;
  }
}

The caller can then branch on error.kind and respond appropriately — retry a network error, surface a user-visible message for a validation failure, log and disable for a parse error.

Graceful Degradation

A plugin that fails to load should not take down the parts of the application that were working. The principle is the same one Kibana applies to required vs optional plugins: required plugins failing is fatal; optional plugins failing is logged and skipped.

For UI plugins, the practical implementation is a fallback component — something the host renders in place of a failed plugin's UI that tells the user what happened without exposing internal error detail:

function PluginErrorBoundary({
  pluginId,
  error,
  children,
}: {
  pluginId: string;
  error: PluginLoadError | null;
  children: React.ReactNode;
}) {
  if (error) {
    return (
      <div role="alert" className="plugin-error">
        <p>The <strong>{pluginId}</strong> plugin could not be loaded.</p>
        {error.kind === 'network' && (
          <p>Check your connection and refresh to try again.</p>
        )}
        {error.kind === 'validation' && (
          <p>This plugin may be incompatible with the current version.</p>
        )}
      </div>
    );
  }
 
  return <>{children}</>;
}

The error message shown to the user should be actionable — "refresh to try again" for a network failure, "contact the plugin author" for a validation failure — rather than a raw error stack.

Retry Logic

Network failures are transient. A loader with bounded retry and exponential back-off handles them without manual intervention:

async function loadWithRetry(
  manifest: PluginManifest,
  maxRetries = 3
): Promise<PluginModule> {
  let lastError: PluginLoadError | undefined;
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await pluginLoader.load(manifest);
    } catch (error) {
      lastError = error as PluginLoadError;
 
      // Only retry network failures
      if (lastError.kind !== 'network') throw lastError;
 
      if (attempt < maxRetries) {
        const delay = Math.min(1000 * 2 ** attempt, 10_000);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
 
  throw lastError;
}

Exponential back-off — 1s, 2s, 4s, capped at 10s — avoids hammering a struggling CDN while still recovering if it stabilises within a reasonable window.

Fallback Plugins

For plugins that are critical to the application's function, a bundled fallback version eliminates the gap between remote load failure and complete unavailability. The fallback is a stripped-down local implementation that covers the core use case without any dependencies on external resources:

async function loadWithFallback(manifest: PluginManifest): Promise<PluginModule> {
  try {
    return await loadWithRetry(manifest);
  } catch (error) {
    const fallback = localFallbacks.get(manifest.id);
    if (fallback) {
      logger.warn(`[${manifest.id}] Using bundled fallback after load failure`);
      return fallback;
    }
    throw error;
  }
}

Fallback plugins should be reserved for truly critical functionality — a payments plugin in an e-commerce application, for example. Bundling a fallback for every optional plugin would undermine the code splitting that makes the system efficient.


Conclusion

Dynamic loading is where the plugin system's runtime behaviour becomes real. Everything in the previous chapters — the contracts, the lifecycle, the registry — is infrastructure. Loading is the moment when a manifest entry field becomes running code.

The range of strategies across production systems reflects the diversity of constraints they face. Vite's native ESM approach is the cleanest model for browser-based systems: no custom loader, no module format negotiation, just import(). VS Code's multi-environment loader is the most complex, because it must handle Node.js, browsers, and remote servers without the plugin author needing to think about which environment they are in. Backstage's approach — treat plugins as normal packages — is the most ergonomic and the right model when the set of plugins is known at build time.

For runtime-loaded plugins from unknown sources, the investment is higher: a universal module loader, CSS isolation, dependency sharing via Module Federation, content-hashed CDN delivery, and a typed error hierarchy with fallbacks. The returns are proportional — a plugin system that supports third-party plugins from an open marketplace needs all of it.

A practical reference: a base dynamic loader adds around 5–10KB to the host bundle. SystemJS adds another 15KB; Module Federation's runtime adds 10KB. Plugin load times should target under 50ms from the same origin and under 200ms from CDN with a warm cache. HMR during development should complete within 300ms to feel instant. These numbers are not aspirational — they are achievable with the approaches in this chapter and worth tracking as the ecosystem grows.

In the next chapter, we will design the SDK that plugin authors interact with directly — the developer-facing API that determines how enjoyable it is to build on the platform.

Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.