Deep Dive: Lazy Loading 42 Backends with Dynamic Imports
Shrinking the emulator's main bundle from 1.1MB to 104KB by lazy-loading backends, and hiding the fetch inside the dial tone.
On this page
The emulator hit a megabyte.
I’d been adding backends steadily — games, language interpreters, CPU emulators, BBS services — and one day the bundle analyzer showed the main chunk at 1.1MB. Every visitor was paying for every backend, even if all they wanted was to dial Zork.
Code splitting is the obvious fix. The part I spent time on was making the split invisible. I didn’t want a loading spinner, and I already had several seconds of dial tone and handshake sitting there doing nothing.
Static Imports, One Giant Chunk
The original registry imported every backend at the top and mapped it by phone number.
// The old way: static imports
import { BasicInterpreter } from '@/backend/basic-interpreter';
import { ForthInterpreter } from '@/backend/forth-interpreter';
import { DungeonCrawler } from '@/backend/dungeon';
import { ZMachineBackend } from '@languages/zil/backend-zmachine';
// ... dozens more imports ...
export const BACKEND_REGISTRY: Record<string, typeof BackendInterface> = {
'5550300': BasicInterpreter,
'5550400': ForthInterpreter,
'5550100': DungeonCrawler,
'5550365': ZMachineBackend,
// ...
};
Vite did what it was asked: bundle everything into the main chunk. The registry held references to every backend class, and every class dragged in its dependencies.
Loader Functions and Dynamic Imports
The registry now stores loader functions instead of classes. Each one does a dynamic import, and Vite splits it into its own chunk.
// web/src/main/modules/backend-registry.ts
export type BackendLoader = () => Promise<new () => BackendInterface>;
export const BACKEND_REGISTRY: Record<string, BackendLoader> = {
// === Games ===
'5550100': () => import('@/backend/dungeon').then((m) => m.DungeonCrawler),
'5550238': () => import('@/backend/adventure').then((m) => m.ColossalCaveAdventure),
// === Language Interpreters ===
'5550300': () => import('@/backend/basic-interpreter').then((m) => m.BasicInterpreter),
'5550365': () => import('@languages/zil/backend-zmachine').then((m) => m.ZMachineBackend),
// ... 42 more loaders ...
} as const;
Vite’s manualChunks config excludes the backend directory from the shared chunks, letting dynamic imports do their work:
// vite.config.ts
manualChunks: (id) => {
// Backend shared infrastructure only - individual backends are lazy-loaded
// via dynamic imports and get their own chunks automatically
if (
id.includes('backend-interface') ||
id.includes('backend-disk-storage') ||
id.includes('cpu-emulator-base')
) {
return 'backend-base';
}
// Don't assign backends to a chunk - let dynamic imports create chunks
};
Now the main bundle is the emulator shell and shared infrastructure. Each backend arrives when it’s dialled.
Preloading During the Dial Tone
Dialling already takes time: dial tone, DTMF digits, ringback, handshake. At 300 baud with a Bell 103 handshake that’s several seconds the user is already expecting to wait through.
When the modem starts dialling, the connection manager kicks off a preload:
// web/src/serial/connection-manager.ts
modemImpl.onDial((number) => {
this.dialedNumber = normalizePhoneNumber(number);
// Start preloading the backend while dial tones play
if (this.backendFactory.preload) {
const preloadPromise = this.backendFactory.preload(this.dialedNumber);
if (preloadPromise) {
this.log(`[ConnectionManager] Preloading backend for ${this.dialedNumber}`);
}
}
});
The RegistryBackendFactory tracks in-flight loads so multiple dials don’t cause duplicate fetches:
// web/src/serial/backend-process-adapter.ts
preload(phoneNumber: string): Promise<void> | null {
const normalized = phoneNumber.replace(/[-\s()]/g, '');
// Already loaded synchronously
if (this.backends.has(normalized)) {
return Promise.resolve();
}
// Already loading
if (this.pendingLoads.has(normalized)) {
return this.pendingLoads.get(normalized)!.then(() => {});
}
// Has a loader - start loading
const loader = this.loaders.get(normalized);
if (loader) {
const loadPromise = loader();
this.pendingLoads.set(normalized, loadPromise);
loadPromise
.then((BackendClass) => {
this.backends.set(normalized, BackendClass);
this.pendingLoads.delete(normalized);
})
.catch((err) => {
this.pendingLoads.delete(normalized);
console.error(`[BackendFactory] Failed to preload ${normalized}:`, err);
});
return loadPromise.then(() => {});
}
return null;
}
By the time the handshake finishes, the backend is usually resident. The fetch happened inside a delay the user was already expecting.
Sync vs Async Creation
Some call paths still need a synchronous backend — tests, tooling, anything that can’t await. The factory supports both:
create(phoneNumber)— synchronous, fails if the backend isn’t already loadedcreateAsync(phoneNumber)— waits for the loader or a pending preload
If you’re dialling, you’re async. If you’re testing, you’re explicit about what you preloaded.
WASM Backends Are a Two-Stage Waterfall
WASM modules add a hop. The backend chunk loads first, then the WASM binary initializes inside onConnect().
// web/src/backend/z80-hello/index.ts
let Z80Emulator: any;
let wasmInitialized = false;
async function initWasm(): Promise<void> {
if (wasmInitialized) return;
const emuModule = await import('@cores/z80/pkg/z80_wasm.js');
await emuModule.default();
Z80Emulator = emuModule.Z80Emulator;
wasmInitialized = true;
}
Preload hides the first hop, the JS chunk, but the WASM binary still initializes after connection. For CPU emulators that’s a few hundred extra milliseconds. The Bell 103 handshake runs about 3 seconds, so there’s usually room; faster modems like V.32bis leave less slack.
Display Names Without Loading Code
The welcome screen needs friendly names (“Zork I”, “BASIC”, “Star Trek”), and lazy loading means the classes aren’t there to ask. A separate mapping lives alongside the registry:
// web/src/main/modules/backend-registry.ts
const PHONE_DISPLAY_NAMES: Record<string, string> = {
'5550100': 'Dungeon Crawler',
'5550238': 'Colossal Cave',
'5550300': 'BASIC',
'5550365': 'Zork I',
// ... all 47 registered numbers ...
};
A duplicated table of strings isn’t elegant, but it keeps the welcome screen instant.
The Tradeoffs
Fast connections can still feel the load. If a backend is large and the dial sequence is short (tone dialling at high baud), createAsync() may still block visibly. The preload helps; it isn’t a guarantee.
More moving parts. The factory has sync and async paths plus a pending-load map, and the connection manager has to coordinate preload timing with dial events. Manageable, but more plumbing to hold in your head.
WASM is still a waterfall. Preload hides the chunk fetch, not the module instantiation. For Z80 or 8088 backends the second hop is noticeable on a slow connection.
Hiding work inside expected delays isn’t new; games have used loading screens for decades. What made this one satisfying was that the delay was already there and doing nothing. The dial tone was three seconds of runway I’d been ignoring.
First paint went from 1.1MB to 104KB.
See also: Deep Dive: Bell 103 Audio Modem — the handshake that supplies the runway