Web Loom logo
Plugin BookPerformance Optimisation and Bundle Management
Part V: Production and Optimization

Performance Optimisation and Bundle Management

Optimise plugin systems for speed and efficient resource usage.

Chapter 14: Performance Optimisation and Bundle Management

Flexibility and performance exist in natural tension in a plugin architecture. Every plugin added to a system brings JavaScript that must be downloaded, parsed, compiled, and executed. Every event subscription is a listener that must be invoked. Every active widget is a timer, a WebSocket connection, or a polling interval that consumes CPU and memory. The flexibility that makes the system useful — any merchant can install any payment gateway, any analyst can add any data source — is the same property that, without care, produces a host application that grows slower as it grows more capable.

The systems studied throughout this book have each developed specific answers to this tension. VS Code achieves sub-second startup despite supporting over 40,000 extensions by loading almost nothing at launch and activating each extension only when a relevant event occurs. Kibana handles 100 to 200 plugins in a typical enterprise installation by parallelising initialisation within dependency tiers, so that independent plugins load concurrently rather than sequentially. NocoBase makes plugin state hot-swappable by persisting it in the database rather than baking it into the startup configuration. Each approach reflects different constraints — VS Code cannot know which of 40,000 extensions a user will actually need; Kibana must be ready within seconds of a browser navigation; NocoBase's administrators need to enable and disable plugins without redeployment.

This chapter examines performance across six areas: process isolation, lazy activation, performance monitoring, bundle optimisation, runtime tuning, and scalability. The techniques compound — a system that applies all of them can handle a large, diverse ecosystem without perceptible degradation; a system that applies none will feel slow with half a dozen plugins.


14.1 Process Isolation Strategies

The most effective performance protection is also the most structural: run plugin code in a separate process so that an expensive computation or a memory leak in a plugin cannot affect the host application's responsiveness.

The VS Code Multi-Process Model

VS Code runs extensions in a dedicated extension host process that communicates with the main UI process via RPC. The UI thread — which handles rendering and input — never executes extension code directly:

const extensionHost = new ExtensionHostProcess({
  processType: 'extensionHost',
  rpcProtocol: 'ipc',
  memoryLimit: '512MB',
  cpuThrottling: true,
});
 
const mainProcess = {
  ui: 'always-responsive',
  extensions: 'isolated-execution',
};

The consequences of this separation are significant: a CPU-intensive extension — a language server, a test runner, a linter — cannot freeze the editor even while processing a large file. A crashing extension does not crash the editor. Memory consumed by extensions is tracked and limited independently of the UI process. Profiling an extension's performance does not require instrumenting the editor itself. These properties are what allow VS Code to host a TypeScript language server, a Git integration, a linter, and a formatter simultaneously on a developer's machine without making the editor feel sluggish.

Web Worker Isolation

For browser-based plugin systems where separate processes are not available, Web Workers provide the same computational isolation at lower overhead. A plugin that needs to analyse a large dataset or run a complex aggregation can offload that work to a worker, keeping the main thread free for UI updates:

class DataAnalysisPlugin {
  private worker: Worker;
 
  async initialize() {
    this.worker = new Worker('/plugins/data-analysis/worker.js');
 
    this.worker.postMessage({
      type: 'analyze-dataset',
      data: largeDataset,
    });
  }
 
  onWorkerMessage(event: MessageEvent) {
    const { progress, result } = event.data;
    this.updateProgress(progress);
  }
}

The worker runs in a separate thread with no access to the DOM. Communication is asynchronous through postMessage, which means the main thread never blocks waiting for the computation. Chapter 9's Web Worker isolation pattern applies here too: worker isolation is both a performance technique and a security one.

Memory Sandboxing

Even plugins running in the main thread can be monitored for memory overconsumption and throttled when they exceed a limit:

class PluginSandbox {
  private memoryLimit = 100 * 1024 * 1024; // 100MB
  private memoryUsage = new Map<string, number>();
 
  async loadPlugin(pluginId: string) {
    const memoryMonitor = setInterval(() => {
      const usage = this.getPluginMemoryUsage(pluginId);
 
      if (usage > this.memoryLimit) {
        console.warn(`Plugin ${pluginId} exceeds memory limit`);
        this.throttlePlugin(pluginId);
      }
    }, 5000);
  }
 
  private throttlePlugin(pluginId: string) {
    // Reduce plugin execution frequency or prompt restart
  }
}

Throttling is a softer response than termination: the plugin continues running but its event handlers are called less frequently. This preserves functionality while reducing the impact on the host. Termination is appropriate only if the plugin has clearly entered an unrecoverable state — a memory leak that keeps growing rather than a temporary spike.


14.2 Activation and Lazy Loading Patterns

Process isolation protects the host from slow plugins. Lazy activation addresses a different problem: plugins that are loaded but never used, consuming startup time and memory for no benefit.

Event-Driven Activation

VS Code's most important performance contribution to the plugin architecture world is the activation event model. Extensions declare which events should trigger their activation rather than loading at startup:

{
  "activationEvents": [
    "onLanguage:typescript",
    "onCommand:myext.deploy",
    "onView:explorer",
    "onStartupFinished"
  ]
}

A TypeScript language extension does not load when VS Code opens a Python file. A deployment tool does not load until the user runs the deploy command. The activation manager maps events to the plugins that care about them and activates only what is needed:

class PluginActivationManager {
  private activationEvents = new Map<string, Plugin[]>();
  private activatedPlugins = new Set<string>();
 
  registerPlugin(plugin: Plugin) {
    for (const event of plugin.activationEvents) {
      if (!this.activationEvents.has(event)) {
        this.activationEvents.set(event, []);
      }
      this.activationEvents.get(event)!.push(plugin);
    }
  }
 
  async triggerActivation(eventType: string, context?: unknown) {
    const plugins = this.activationEvents.get(eventType) || [];
 
    const activationPromises = plugins
      .filter((p) => !this.activatedPlugins.has(p.id))
      .map(async (plugin) => {
        try {
          await plugin.activate(context);
          this.activatedPlugins.add(plugin.id);
        } catch (error) {
          console.error(`Failed to activate ${plugin.id}:`, error);
        }
      });
 
    await Promise.allSettled(activationPromises);
  }
}

Promise.allSettled rather than Promise.all is the right choice here: if one plugin's activation fails, the others should still proceed. A broken TypeScript extension should not prevent the Git integration from activating.

Progressive Loading Strategy

For applications where activation events do not map cleanly onto user actions — a dashboard that shows data from many plugins simultaneously, for instance — tiered loading provides a structured alternative:

class ProgressivePluginLoader {
  async loadPlugins() {
    // Tier 1: Critical plugins (authentication, core navigation)
    await this.loadCriticalPlugins();
 
    // Tier 2: Common plugins after UI is interactive
    requestIdleCallback(() => this.loadCommonPlugins());
 
    // Tier 3: Optional plugins after first user interaction
    document.addEventListener(
      'user-interaction',
      () => { this.loadOptionalPlugins(); },
      { once: true },
    );
  }
 
  private async loadCriticalPlugins() {
    const critical = ['auth', 'navigation', 'error-handler'];
    await Promise.all(critical.map((id) => this.loadPlugin(id)));
  }
 
  private async loadCommonPlugins() {
    const common = ['user-profile', 'notifications', 'search'];
    for (const id of common) {
      await new Promise((resolve) => setTimeout(resolve, 100));
      await this.loadPlugin(id);
    }
  }
}

The 100ms delay between common plugin loads is intentional yielding: it gives the browser an opportunity to process user input and paint frames between loads, which prevents the "loading many things at once" sensation even when the total load time is the same.

Predictive Preloading

Usage analytics can shift the activation timing further: if 85% of users who open the reporting section immediately navigate to the revenue chart, preloading the revenue chart plugin during idle time after the reporting section opens removes a noticeable delay:

class PredictivePluginLoader {
  private analytics = new PluginAnalytics();
 
  async preloadLikelyPlugins(userId: string) {
    const predictions = await this.analytics.getPredictions(userId);
 
    for (const { pluginId, probability } of predictions) {
      if (probability > 0.8) {
        requestIdleCallback(() => {
          this.preloadPlugin(pluginId);
        });
      }
    }
  }
 
  private async preloadPlugin(pluginId: string) {
    // Download and parse, but do not activate yet
    const plugin = await import(`/plugins/${pluginId}`);
    this.pluginCache.set(pluginId, plugin);
  }
}

The distinction between preloading and activation matters: preloading downloads and parses the module, paying the network and parse cost early. Activation — running setup and start — still happens on demand. The user perceives instant response because the module is already in memory when the activation trigger fires.


14.3 Performance Monitoring

Lazy loading and process isolation prevent performance problems from accumulating. Performance monitoring identifies the problems that slip through anyway and makes it possible to act on them before they affect users.

The monitoring surface for a plugin system spans three dimensions. Load metrics cover the cost of bringing a plugin into the system: network download time, parse and compile time, dependency resolution, activation and initialisation time, and time to first interaction. Runtime metrics cover the cost of a plugin being active: heap size, DOM node count, CPU time for key operations, event loop blocking duration, bundle size in compressed form, and cache effectiveness. User experience metrics correlate the technical measurements with what users perceive: time to plugin functionality, UI responsiveness during plugin operations, and error rates.

The performance.mark and performance.measure APIs provide the underlying instrumentation without adding significant overhead:

class PluginPerformanceMonitor {
  private metrics = new Map<string, PluginMetrics>();
 
  measurePluginLoad(pluginId: string) {
    return {
      networkStart: () => performance.mark(`${pluginId}-network-start`),
      networkEnd: () => performance.mark(`${pluginId}-network-end`),
      parseStart: () => performance.mark(`${pluginId}-parse-start`),
      parseEnd: () => performance.mark(`${pluginId}-parse-end`),
      activationStart: () => performance.mark(`${pluginId}-activation-start`),
      activationEnd: () => {
        performance.mark(`${pluginId}-activation-end`);
        performance.measure(`${pluginId}-total`, `${pluginId}-network-start`, `${pluginId}-activation-end`);
        this.recordMetrics(pluginId);
      },
    };
  }
 
  private recordMetrics(pluginId: string) {
    const measures = performance.getEntriesByName(`${pluginId}-total`);
    const totalTime = measures[0]?.duration || 0;
    const memUsage = (performance as any).memory;
 
    this.metrics.set(pluginId, {
      loadTime: totalTime,
      memoryUsed: memUsage?.usedJSHeapSize,
      bundleSize: this.getBundleSize(pluginId),
      timestamp: Date.now(),
    });
  }
}

Resource Usage Tracking at Scale

For systems running many plugins simultaneously, a PerformanceObserver watching for long tasks catches the event loop blocks that users experience as jank — the visual stutter that occurs when a plugin holds the main thread for more than 50ms:

class PluginResourceMonitor {
  private observers = new Map<string, PerformanceObserver>();
 
  monitorPlugin(pluginId: string) {
    this.startMemoryMonitoring(pluginId);
    this.monitorEventLoop(pluginId);
    this.monitorNetworkRequests(pluginId);
    this.monitorDOMImpact(pluginId);
  }
 
  private startMemoryMonitoring(pluginId: string) {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name.includes(pluginId)) {
          this.recordMemoryUsage(pluginId, entry);
        }
      }
    });
 
    observer.observe({ entryTypes: ['measure', 'navigation'] });
    this.observers.set(pluginId, observer);
  }
 
  private monitorEventLoop(pluginId: string) {
    const longTaskObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.duration > 50) {
          console.warn(`Plugin ${pluginId} blocked event loop for ${entry.duration}ms`);
          this.recordEventLoopBlock(pluginId, entry.duration);
        }
      }
    });
 
    longTaskObserver.observe({ entryTypes: ['longtask'] });
  }
 
  monitorParallelInitialization(plugins: Plugin[]) {
    const tiers = this.groupByDependencyTier(plugins);
 
    for (const tier of tiers) {
      const tierStart = performance.now();
 
      Promise.allSettled(tier.map((plugin) => this.initializePlugin(plugin))).then(() => {
        const tierDuration = performance.now() - tierStart;
        console.log(`Tier initialisation took ${tierDuration}ms for ${tier.length} plugins`);
      });
    }
  }
}

User Experience Correlation and Performance Budgets

Raw performance numbers are most useful when correlated with user-visible outcomes. A checkout plugin that adds 80ms to the time from navigation to the payment form appearing is measurable in conversion rates for a high-traffic merchant; one that adds 80ms to the time a confirmation modal appears is not detectable. Knowing which measurements matter means instrumenting user-journey milestones — "time to checkout form interactive", "time to dashboard widgets rendered" — not just abstract load times.

Performance budgets give those milestones enforceable thresholds. VS Code targets under 50ms activation time per extension. Kibana targets under 100MB memory per plugin. A UI component library might target under 2KB gzipped per behaviour module. The specific numbers are less important than having numbers at all: a budget that flags violations in CI catches regressions before they reach users. Without a budget, each slow plugin accumulates until the aggregate impact becomes undeniable, at which point it is much harder to identify which plugin is responsible.


14.4 Bundle Optimisation Strategies

Lazy activation avoids loading plugins unnecessarily. Bundle optimisation ensures that when a plugin does load, it loads as little code as possible as quickly as possible.

Hierarchical Code Splitting

A plugin bundle does not need to arrive as a single file. Feature-based code splitting separates the core entry point — which must load for any plugin interaction — from the feature modules that are only needed when a user navigates to a specific section:

const pluginBundle = {
  entry: () => import('./plugin-entry'),
 
  features: {
    visualization: () => import('./features/visualization'),
    configuration: () => import('./features/configuration'),
    reporting: () => import('./features/reporting'),
  },
 
  components: {
    heavyChart: () => import('./components/heavy-chart'),
    dataTable: () => import('./components/data-table'),
  },
};
 
class FeatureRouter {
  async navigateToFeature(feature: string) {
    if (!this.loadedFeatures.has(feature)) {
      const featureModule = await pluginBundle.features[feature]();
      this.loadedFeatures.set(feature, featureModule);
    }
 
    return this.loadedFeatures.get(feature);
  }
}

A plugin that has a configuration panel, a reporting view, and a visualisation engine can load only the visualisation code when the user first opens a dashboard. The configuration panel code loads when they click "Settings" for the first time. The reporting engine loads if they ever navigate to the export tab. Each feature module is cached after its first load, so subsequent navigations are instant.

Tree Shaking

Tree shaking requires cooperation from the plugin's package structure. A plugin that exports everything from a single barrel file cannot be tree-shaken — the bundler cannot know which exports are used without running the code. Granular exports with individual entry points per feature allow the build tool to include only what is imported:

{
  "sideEffects": false,
  "exports": {
    "./behaviors/dialog": "./dist/behaviors/dialog.js",
    "./behaviors/disclosure": "./dist/behaviors/disclosure.js",
    "./patterns/command-palette": "./dist/patterns/command-palette.js"
  }
}
// ❌ Barrel export prevents tree shaking
// export * from './behaviors';
 
// ✅ Granular exports allow tree shaking
export { createDialogBehavior } from './behaviors/dialog';
export { createDisclosureBehavior } from './behaviors/disclosure';
 
const buildConfig = {
  lib: {
    entry: {
      'behaviors/dialog': './src/behaviors/dialog.ts',
      'behaviors/disclosure': './src/behaviors/disclosure.ts',
    },
    formats: ['es'],
  },
  rollupOptions: {
    output: {
      preserveModules: true,
      preserveModulesRoot: 'src',
    },
  },
};

Automated bundle size tests enforce these constraints in CI, catching regressions before they ship:

class TreeShakingValidator {
  async validatePluginBundleSize(pluginId: string) {
    const testCases = [
      {
        name: 'dialog-only',
        code: `import { createDialogBehavior } from '${pluginId}/behaviors/dialog';`,
      },
      {
        name: 'all-features',
        code: `import * from '${pluginId}';`,
      },
    ];
 
    for (const test of testCases) {
      const bundle = await this.buildTestBundle(test.code);
      const gzippedSize = this.gzipSize(bundle);
 
      if (gzippedSize > this.getSizeLimit(test.name)) {
        throw new Error(`${test.name} bundle too large: ${gzippedSize} bytes`);
      }
    }
  }
}

Module Deduplication and Shared Dependencies

In an ecosystem with many plugins, each independently bundling React and its own copy of a date library, the total JavaScript delivered to the browser can be several times larger than necessary. Module Federation eliminates this by declaring shared dependencies that the host exposes to all plugins:

// Host app — declares shared dependencies
new ModuleFederationPlugin({
  name: 'host',
  shared: {
    react: { singleton: true, eager: true },
    'react-dom': { singleton: true, eager: true },
    '@company/ui-lib': { singleton: true },
    lodash: { singleton: false }, // Allow multiple versions where needed
  },
});
 
// Plugin — declares the same shared dependencies
new ModuleFederationPlugin({
  name: 'my-plugin',
  exposes: {
    './Plugin': './src/plugin-entry',
  },
  shared: {
    react: { singleton: true },
    'react-dom': { singleton: true },
  },
});

NocoBase takes a simpler version of this approach by providing common utilities through the SDK itself:

class PluginSDK {
  ui = {
    React: require('react'),
    ReactDOM: require('react-dom'),
    lodash: require('lodash'),
    dayjs: require('dayjs'),
  };
 
  services = {
    http: new HttpClient(),
    storage: new StorageService(),
    events: new EventBus(),
  };
}

Plugins use this.sdk.ui.React rather than importing React directly, which means their bundles contain no React code — they use the single copy the host provides. The dependency analyser identifies which shared libraries are worth hosting at the platform level:

class DependencyAnalyzer {
  analyzeDuplication(plugins: Plugin[]) {
    const dependencyMap = new Map<string, Plugin[]>();
 
    for (const plugin of plugins) {
      for (const dep of plugin.dependencies) {
        if (!dependencyMap.has(dep)) {
          dependencyMap.set(dep, []);
        }
        dependencyMap.get(dep)!.push(plugin);
      }
    }
 
    return Array.from(dependencyMap.entries())
      .filter(([, plugins]) => plugins.length > 1)
      .map(([dep, plugins]) => ({
        dependency: dep,
        pluginCount: plugins.length,
        estimatedWaste: this.calculateWastedBytes(dep, plugins.length),
      }));
  }
}

Dynamic Import for Remote Plugins

When a plugin is loaded from a URL at runtime — as opposed to being bundled with the host — the webpackIgnore magic comment prevents the build tool from trying to resolve the URL at build time:

const plugin = await import(/* webpackIgnore: true */ pluginUrl);

This is the mechanism that makes marketplace plugins work: the host downloads and evaluates the plugin's bundle at runtime without knowing its URL at build time. The pattern pairs with the SRI integrity verification from chapter 9: the manifest provides both the URL and the expected hash, and the host verifies the hash before evaluating the module.


14.5 Runtime Performance Tuning

Getting plugins loaded efficiently is half the performance story. The other half is how they behave once they are running.

Priority-Based Loading

Not all plugin initialisation is equally urgent. A priority queue ensures that authentication and navigation load synchronously, frequently used features load in the background without blocking the main thread, and rarely needed capabilities load only when requested:

class PriorityPluginLoader {
  private loadQueues = {
    critical: [] as Plugin[],
    high: [] as Plugin[],
    normal: [] as Plugin[],
    low: [] as Plugin[],
  };
 
  async loadByPriority() {
    await this.loadQueue('critical');
    setTimeout(() => this.loadQueue('high'), 0);
    requestIdleCallback(() => this.loadQueue('normal'));
    this.deferQueue('low');
  }
 
  private async loadQueue(priority: keyof typeof this.loadQueues) {
    const queue = this.loadQueues[priority];
 
    if (priority === 'critical') {
      await Promise.all(queue.map((plugin) => this.loadPlugin(plugin)));
    } else {
      for (const plugin of queue) {
        await this.loadPlugin(plugin);
        await new Promise((resolve) => setTimeout(resolve, 10));
      }
    }
  }
}

Critical plugins load in parallel — their combined load time equals the slowest one, not their sum. Non-critical plugins load sequentially with a 10ms yield between each, giving the browser frames to paint and input events to process.

Memory Management

At scale, plugins that accumulate memory without releasing it are a significant operational problem. The memory manager monitors each plugin's heap usage and applies proportionate responses — reducing cache sizes at mild pressure, clearing caches at moderate pressure, forcing garbage collection at critical pressure:

class PluginMemoryManager {
  private memoryLimits = new Map<string, number>();
  private cleanupStrategies = new Map<string, CleanupStrategy>();
 
  monitorMemoryUsage() {
    setInterval(() => {
      for (const [pluginId, limit] of this.memoryLimits) {
        const usage = this.getPluginMemoryUsage(pluginId);
 
        if (usage > limit) {
          this.handleMemoryPressure(pluginId, usage, limit);
        }
      }
    }, 10_000);
  }
 
  private async handleMemoryPressure(pluginId: string, usage: number, limit: number) {
    const strategy = this.cleanupStrategies.get(pluginId);
 
    if (usage > limit * 1.5) {
      await this.forceGarbageCollection(pluginId);
    } else if (usage > limit * 1.2) {
      await strategy?.cleanCaches?.();
    } else {
      await strategy?.reduceCacheSize?.();
    }
  }
 
  async managePluginLifecycle() {
    const activePlugins = this.getActivePlugins();
    const lastUsed = new Map<string, number>();
 
    for (const plugin of activePlugins) {
      const timeSinceLastUse = Date.now() - (lastUsed.get(plugin.id) || 0);
 
      if (timeSinceLastUse > 10 * 60 * 1000) {
        await this.suspendPlugin(plugin.id);
      }
    }
  }
}

Plugin suspension — stopping a plugin and releasing its resources while keeping it registered — is a technique for long-running sessions where users may not interact with all installed plugins for extended periods. A data analysis plugin that has not been used in ten minutes can safely release its worker pool and cached results; it can be reactivated when the user returns to it.

Cooperative Scheduling and DOM Batching

Plugins that do work in response to events can starve the main thread if their handlers are synchronous and long-running. A cooperative scheduler uses requestIdleCallback to process plugin tasks during browser idle periods, and batches DOM mutations across plugins into single animation frames:

class PluginScheduler {
  private taskQueue: PluginTask[] = [];
  private isRunning = false;
 
  schedulePluginWork(pluginId: string, task: PluginTask) {
    this.taskQueue.push({ ...task, pluginId, priority: task.priority || 0 });
 
    if (!this.isRunning) {
      this.startWorkLoop();
    }
  }
 
  private startWorkLoop() {
    this.isRunning = true;
 
    const workLoop = (deadline: IdleDeadline) => {
      while (deadline.timeRemaining() > 0 && this.taskQueue.length > 0) {
        const task = this.taskQueue.shift()!;
 
        try {
          task.execute();
        } catch (error) {
          console.error(`Plugin ${task.pluginId} task failed:`, error);
        }
      }
 
      if (this.taskQueue.length > 0) {
        requestIdleCallback(workLoop, { timeout: 100 });
      } else {
        this.isRunning = false;
      }
    };
 
    requestIdleCallback(workLoop);
  }
 
  batchDOMUpdates(updates: DOMUpdate[]) {
    requestAnimationFrame(() => {
      const grouped = this.groupUpdatesByType(updates);
 
      for (const styleUpdate of grouped.styles) {
        styleUpdate.apply();
      }
 
      for (const domUpdate of grouped.dom) {
        domUpdate.apply();
      }
    });
  }
}

The timeout: 100 on requestIdleCallback is a deadline: if the browser has not entered an idle period within 100ms, the callback runs anyway. Without this, low-priority plugin work could be indefinitely deferred on a busy page.

Worker Pools for Heavy Computation

Plugins that perform CPU-intensive operations — statistical analysis, image processing, cryptographic operations — should run in workers rather than blocking the main thread. A worker pool amortises the startup cost of worker creation across multiple plugin requests:

class PluginWorkerPool {
  private workers: Worker[] = [];
 
  constructor(poolSize: number = navigator.hardwareConcurrency || 4) {
    for (let i = 0; i < poolSize; i++) {
      this.workers.push(new Worker('/plugin-worker.js'));
    }
  }
 
  async executeInWorker(pluginId: string, computation: ComputeTask) {
    return new Promise((resolve, reject) => {
      const worker = this.getAvailableWorker();
 
      worker.postMessage({
        type: 'compute',
        pluginId,
        data: computation.data,
        algorithm: computation.algorithm,
      });
 
      worker.onmessage = (event) => {
        const { success, result, error } = event.data;
 
        if (success) {
          resolve(result);
        } else {
          reject(new Error(error));
        }
 
        this.releaseWorker(worker);
      };
    });
  }
}

Network Request Deduplication

When multiple plugins need the same data — say, the current user's profile — each making an independent request produces unnecessary network traffic and response processing. Request deduplication in the SDK's API client collapses concurrent identical requests into a single in-flight fetch:

class DeduplicatingApiClient {
  private inFlight = new Map<string, Promise<unknown>>();
 
  async get<T>(url: string, params?: Record<string, unknown>): Promise<T> {
    const cacheKey = JSON.stringify({ url, params });
    const existing = this.inFlight.get(cacheKey);
 
    if (existing) {
      return existing as Promise<T>;
    }
 
    const request = fetch(buildUrl(url, params))
      .then((r) => r.json())
      .finally(() => {
        this.inFlight.delete(cacheKey);
      });
 
    this.inFlight.set(cacheKey, request);
    return request as Promise<T>;
  }
}

If three plugins call sdk.services.apiClient.get('/users/me') within the same event loop tick, only one HTTP request is made. All three receive the same response. The in-flight entry is cleared when the request settles, so subsequent calls go through normally.


14.6 Development vs Production

The optimisations described above are production concerns. The development environment has a different priority: making it fast to iterate, easy to debug, and possible to diagnose performance problems before they reach production.

Build Configuration

A development build prioritises rebuild speed over output size. Source maps are enabled at full fidelity so that browser devtools show original TypeScript source, not minified output. Hot Module Replacement means that editing a plugin's source file updates the running application without a full reload, preserving state across changes. Tree shaking and minification are disabled because they are slow and make debug output harder to read.

A production build inverts these priorities. Minification reduces download size; tree shaking removes unused code; code splitting creates granular chunks that browsers can cache independently. The build takes longer, but it only runs in CI.

A shared Vite configuration that varies by environment keeps this distinction explicit:

import { defineConfig } from 'vite';
 
export default defineConfig(({ mode }) => ({
  build: {
    minify: mode === 'production',
    sourcemap: mode !== 'production',
    rollupOptions: {
      output: {
        manualChunks: mode === 'production'
          ? { vendor: ['react', 'react-dom'] }
          : undefined,
      },
    },
  },
  plugins: [
    mode === 'development' && hotModuleReplacementPlugin(),
  ].filter(Boolean),
}));

Source Map Management

Source maps in production are a trade-off between debuggability and intellectual property protection. Shipping full source maps means that the original TypeScript source of every plugin is accessible to anyone who opens devtools. The pragmatic middle ground is to generate source maps during the production build but upload them to an error tracking service (Sentry, Datadog) rather than shipping them alongside the bundle. Stack traces in error reports show original source lines; browser devtools show minified output.

// Upload source maps to error tracker, then delete them from the build output
const uploadSourceMaps = async (buildDir: string) => {
  await errorTracker.uploadSourceMaps({
    path: buildDir,
    release: process.env.BUILD_VERSION,
  });
 
  // Remove .map files from deployment artefacts
  await fs.glob(`${buildDir}/**/*.map`).then((files) =>
    Promise.all(files.map((f) => fs.unlink(f)))
  );
};

Development Tooling

A plugin performance panel in development mode surfaces the metrics that would otherwise require manual instrumentation. It shows each active plugin's load time, current memory usage, event handler count, and event loop block duration. Seeing that a plugin has registered 47 event listeners where you expected 3 is the kind of insight that prevents a memory leak from shipping:

class PluginDevPanel {
  render(): void {
    if (process.env.NODE_ENV !== 'development') return;
 
    const metrics = Array.from(this.monitor.getAllMetrics());
    console.table(
      metrics.map(([id, m]) => ({
        plugin: id,
        loadTime: `${m.loadTime.toFixed(1)}ms`,
        memory: `${(m.memoryUsed / 1024 / 1024).toFixed(1)}MB`,
        listeners: m.listenerCount,
        longTasks: m.longTaskCount,
      }))
    );
  }
}

Production Monitoring

In production, plugin performance data flows to an observability platform. New Relic, Datadog, and similar services can alert when a specific plugin's activation time spikes above its historical baseline, when its memory usage grows monotonically across sessions (indicating a leak), or when its error rate exceeds a threshold. The key is attributing measurements to specific plugins rather than lumping them into undifferentiated "JavaScript time" — without that attribution, diagnosing a performance regression requires guesswork.


14.7 Enterprise Scalability Patterns

Individual plugin optimisations compound into system-level decisions at enterprise scale, where hundreds of plugins must initialise correctly and efficiently across potentially many server instances.

Multi-Tier Parallel Initialisation

Kibana's parallel initialisation approach groups plugins by dependency depth and loads each tier concurrently. A plugin with no dependencies is in tier 0 and loads in the first wave. A plugin that depends on a tier-0 plugin is in tier 1 and loads in the second wave. Within each wave, all plugins load in parallel:

class EnterprisePluginLoader {
  async loadPluginEcosystem(plugins: Plugin[]) {
    const tiers = this.buildDependencyTiers(plugins);
 
    for (const [tierLevel, tierPlugins] of tiers.entries()) {
      const tierStart = performance.now();
 
      await Promise.allSettled(tierPlugins.map((plugin) => this.initializePlugin(plugin)));
 
      const tierDuration = performance.now() - tierStart;
      console.log(`Tier ${tierLevel} loaded in ${tierDuration}ms`);
    }
  }
 
  private buildDependencyTiers(plugins: Plugin[]): Map<number, Plugin[]> {
    const tiers = new Map<number, Plugin[]>();
    const visited = new Set<string>();
 
    const calculateTier = (plugin: Plugin): number => {
      if (visited.has(plugin.id)) return 0;
      visited.add(plugin.id);
 
      if (!plugin.dependencies.length) return 0;
 
      const dependencyTiers = plugin.dependencies.map((depId) => {
        const dep = plugins.find((p) => p.id === depId);
        return dep ? calculateTier(dep) : 0;
      });
 
      return Math.max(...dependencyTiers) + 1;
    };
 
    for (const plugin of plugins) {
      const tier = calculateTier(plugin);
      if (!tiers.has(tier)) tiers.set(tier, []);
      tiers.get(tier)!.push(plugin);
    }
 
    return tiers;
  }
}

The practical impact is significant: if a system has 50 independent plugins that each take 20ms to initialise, sequential loading takes 1,000ms and parallel loading takes 20ms. With dependency tiers, the actual time is bounded by the depth of the dependency graph, not the number of plugins.

Database-Driven Plugin State

NocoBase's model of persisting plugin state in the database rather than configuration files enables administrators to enable and disable plugins at runtime across all instances of a horizontally scaled deployment. The manager reconciles the database state with what is currently loaded:

class DatabasePluginManager {
  async syncPluginState() {
    const dbPlugins = await this.db.getEnabledPlugins();
    const fileSystemPlugins = await this.scanFileSystem();
 
    const toEnable = dbPlugins.filter(
      (p) => fileSystemPlugins.has(p.id) && !this.isLoaded(p.id)
    );
 
    const toDisable = this.loadedPlugins.filter(
      (p) => !dbPlugins.find((db) => db.id === p.id)
    );
 
    await this.enablePlugins(toEnable);
    await this.disablePlugins(toDisable);
  }
 
  async updatePluginState(pluginId: string, enabled: boolean) {
    await this.db.updatePluginState(pluginId, enabled);
 
    if (enabled) {
      await this.hotLoadPlugin(pluginId);
    } else {
      await this.hotUnloadPlugin(pluginId);
    }
  }
}

When an administrator disables a plugin through the UI, the database record changes, all instances polling for state reconciliation detect the change, and each unloads the plugin within their next reconciliation cycle — without any of those instances restarting.

Global CDN and Edge Distribution

For geographically distributed user bases, serving plugin bundles from edge nodes close to each user's location reduces the network component of load time. Usage analytics determine which plugins are popular enough in each region to justify edge caching:

class GlobalPluginCDN {
  private edgeNodes = new Map<string, EdgeNode>();
 
  async optimizePluginDistribution() {
    const pluginUsageStats = await this.getGlobalUsageStats();
 
    for (const [region, stats] of pluginUsageStats) {
      const popularPlugins = stats
        .filter((p) => p.usage > 0.8)
        .map((p) => p.pluginId);
 
      await this.preloadToEdge(region, popularPlugins);
    }
  }
 
  private async preloadToEdge(region: string, pluginIds: string[]) {
    const edgeNode = this.edgeNodes.get(region);
 
    await Promise.all(
      pluginIds.map(async (pluginId) => {
        const pluginBundle = await this.getPluginBundle(pluginId);
        await edgeNode?.cache(pluginId, pluginBundle);
      }),
    );
  }
}

Horizontal Scaling Coordination

When multiple application instances serve the same user base, plugin state changes in one instance need to propagate to the others. A coordinator handles cluster-wide state and distributes optimisation recommendations based on aggregated analytics:

class DistributedPluginManager {
  private coordinator: PluginCoordinator;
 
  async initializeCluster() {
    await this.coordinator.registerInstance(this.instanceId);
 
    this.coordinator.onPluginStateChange((pluginId, state) => {
      this.handleClusterStateChange(pluginId, state);
    });
 
    setInterval(() => { this.shareAnalytics(); }, 60_000);
  }
 
  private async shareAnalytics() {
    const localStats = this.gatherLocalStats();
    await this.coordinator.reportStats(this.instanceId, localStats);
 
    const recommendations = await this.coordinator.getOptimizationRecommendations();
    await this.applyRecommendations(recommendations);
  }
 
  private async applyRecommendations(recs: OptimizationRecommendation[]) {
    for (const rec of recs) {
      switch (rec.type) {
        case 'preload':
          await this.preloadPlugin(rec.pluginId);
          break;
        case 'unload':
          await this.unloadUnusedPlugin(rec.pluginId);
          break;
        case 'cache':
          await this.adjustCacheSize(rec.pluginId, rec.size);
          break;
      }
    }
  }
}

Conclusion

Performance in a plugin system is not a single problem with a single solution. VS Code's process isolation, Kibana's parallel tier initialisation, NocoBase's database-driven state reconciliation, and Vite's tree shaking each address a different part of the overhead that a plugin ecosystem introduces. VS Code achieves sub-50ms activation per extension at 40,000 extension scale; Kibana keeps memory per plugin below 100MB across a hundred-plugin enterprise installation; a well-structured UI component library holds each behaviour module under 2KB gzipped. These numbers are achievable, but they require deliberate architectural choices at each layer — not just one clever technique applied at the end.

The monitoring infrastructure is the foundation that makes everything else maintainable. Without measurements attributed to specific plugins, regressions are discovered by users rather than by engineers. With them, a slow activation or a growing memory footprint becomes a visible metric that can be addressed before it compounds into a systemic problem.

The next chapter addresses distribution — how to get plugins from a developer's machine to a user's installation reliably, how to manage version compatibility as both plugins and the host evolve, and how to operate a plugin registry at scale.

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