Skip to content

Implementing an Asynchronous Module Loader in QuickJS

When working with QuickJS, executing JavaScript modules inside a sandboxed environment presents a challenge:

Modules need to be dynamically loaded—especially when fetching remote dependencies.
Some dependencies might not be available upfront—they must be fetched at runtime.
Using synchronous module resolution can block execution—asynchronous loading is more efficient.

The default module loader in QuickJS works synchronously, meaning all modules must be preloaded into the virtual file system.

However, what if we want to:

  • Load modules from external sources like esm.sh?
  • Dynamically fetch modules during execution?
  • Implement server-side rendering (SSR) for JavaScript frameworks like React?

The answer: An Asynchronous Module Loader. 🚀

🏗️ Why Use an Asynchronous Module Loader?

🔹 Synchronous vs. Asynchronous Loading

FeatureSynchronous LoaderAsynchronous Loader
Module AvailabilityMust exist before executionCan be fetched dynamically
PerformanceBlocks execution while readingNon-blocking, better for large apps
External DependenciesDifficult to supportCan load from CDNs like esm.sh
Use CaseSmall apps, static filesServer-side rendering, remote modules

💡 Asynchronous module loading is essential when working with dynamic environments—such as SSR, AI-generated code execution, and external API calls.

🔄 Implementing an Asynchronous Module Loader

We’ll implement a custom async loader that:

Handles local modules from the virtual file system
Supports remote module fetching from esm.sh
Resolves relative imports within remote modules

🔹 Step 1: Path Normalization for Async Modules

We need to ensure that module paths are correctly resolved before loading them asynchronously.

ts
const modulePathNormalizer = async (baseName: string, requestedName: string) => {
  // Fetch from esm.sh
  if (requestedName.startsWith('esm.sh') || requestedName.startsWith('https://esm.sh')) {
    return requestedName.startsWith('https://') ? requestedName : `https://${requestedName}`
  }

  // Relative imports within esm.sh modules
  if (requestedName.startsWith('/')) {
    return `https://esm.sh${requestedName}`
  }

  // Handle relative imports from local sources
  if (requestedName.startsWith('.')) {
    if (baseName.startsWith('https://esm.sh')) {
      return new URL(requestedName, baseName).toString()
    }
    return resolve(`/${baseName.split('/').slice(0, -1).join('/')}`, requestedName)
  }

  // Normalize Node.js built-in modules
  const moduleName = requestedName.replace('node:', '')
  return join('/node_modules', moduleName)
}

🔹 Step 2: Fetching Modules Asynchronously

Since QuickJS does not automatically fetch remote modules, we need to define an async loader:

ts
import type { QuickJSAsyncContext } from 'quickjs-emscripten-core'
import { getAsyncModuleLoader } from 'quickjs-emscripten-core'

const getModuleLoader = (fs, runtimeOptions) => {
  const defaultLoader = getAsyncModuleLoader(fs, runtimeOptions)

  const loader = async (moduleName: string, context: QuickJSAsyncContext) => {
    console.log('Fetching module:', moduleName)

    // Load from local filesystem first
    if (!moduleName.startsWith('https://esm.sh')) {
      return defaultLoader(moduleName, context)
    }

    // Fetch from esm.sh
    const response = await fetch(moduleName)
    if (!response.ok) {
      throw new Error(`Failed to load module ${moduleName}`)
    }

    return await response.text()
  }

  return loader
}

This implementation enables QuickJS to fetch modules dynamically from esm.sh!

🔹 Step 3: Running Async Code with QuickJS

Now, let’s execute an SSR example, fetching React and rendering HTML dynamically.

ts
import { type SandboxAsyncOptions, loadAsyncQuickJs } from '@sebastianwessel/quickjs'

const { runSandboxed } = await loadAsyncQuickJs()

const options: SandboxAsyncOptions = {
  modulePathNormalizer,
  getModuleLoader,
}

const code = `
import * as React from 'esm.sh/react@18'
import * as ReactDOMServer from 'esm.sh/react-dom@18/server'

const e = React.createElement
export default ReactDOMServer.renderToStaticMarkup(
  e('div', null, e('strong', null, 'Hello world!'))
)
`

const result = await runSandboxed(async ({ evalCode }) => evalCode(code), options)

console.log(result)
// Output: "<div><strong>Hello world!</strong></div>"

🎯 Key Takeaways

Synchronous module loading is limited—all modules must be preloaded.
Asynchronous module loaders enable dynamic imports, making QuickJS more powerful.
With an async loader, we can fetch modules from sources like esm.sh at runtime.
This approach is ideal for SSR, AI-generated code execution, and dynamic environments.

🔗 Next Steps

🛠️ Try it out! Modify the loader to fetch modules from different CDNs.
🔍 Optimize performance—cache fetched modules for faster execution.
🚀 Extend support—enable async module loading for larger applications.

QuickJS makes secure and efficient JavaScript execution possible—now with dynamic imports!