Skip to content

fix: defer WASM init in bindings to first use - #673

Open
JakeChampion wants to merge 1 commit into
nuxt-modules:mainfrom
JakeChampion:fix/defer-wasm-init-to-first-use
Open

fix: defer WASM init in bindings to first use#673
JakeChampion wants to merge 1 commit into
nuxt-modules:mainfrom
JakeChampion:fix/defer-wasm-init-to-first-use

Conversation

@JakeChampion

Copy link
Copy Markdown

What

The wasm bindings for resvg, satori and takumi start their init chain at module-evaluation time:

export default {
  initWasmPromise: importWasm(import('@resvg/resvg-wasm/index_bg.wasm?module' as string))
    .then(wasm => initWasm(wasm)),
  Resvg: _Resvg,
}

This PR exposes initWasmPromise as a lazy, memoized getter instead. The object shape consumed by getResvg / getSatori / getTakumi is unchanged; the init chain now only starts on the first property access, which happens inside the render path.

Why

The bindings were written to be lazy at the module level - import('#og-image/bindings/resvg') inside getResvg() - which works when the bundler keeps dynamic imports as separate chunks. But several deployment targets bundle the server as a single file: Nitro's base-worker family sets inlineDynamicImports: true (netlify-edge, and others inherit it). Rollup then inlines the "lazy" binding module into the entry chunk, and since ESM top-level code runs at module-link time, the WASM fetch + WebAssembly.instantiate fires as soon as the isolate boots.

Net effect on those targets: every cold start pays WASM compilation for resvg (and yoga for satori), including plain page loads that never render an OG image. On edge runtimes with per-request CPU budgets and frequent cold starts, this dominates cold-start latency. We measured this in production on Netlify Edge Functions, where the built server.js contained the resvg initWasmPromise as a top-level const whose init chain ran unconditionally at boot.

With this change, single-file bundles still contain the WASM, but compilation is deferred to the first OG-image render on that isolate.

Changes

  • bindings/resvg/wasm.ts, bindings/resvg/wasm-fs.ts
  • bindings/satori/wasm.ts, bindings/satori/wasm-fs.ts
  • bindings/takumi/wasm.ts

Each replaces the eagerly-created initWasmPromise property with a get initWasmPromise() that creates the promise on first access and memoizes it. The node / node-dev bindings (Promise.resolve()) are untouched.

Verification

  • pnpm lint and pnpm typecheck pass.
  • Behavioral check: importing bindings/resvg/wasm.ts no longer triggers any WASM loading; the load attempt happens exactly on first initWasmPromise access (verified by importing the module in isolation and observing the resolution attempt only fires on property access).
  • Complementary Nitro-side fix (allowing code-splitting in the netlify-edge preset) is being proposed separately, but this change fixes the eager init for every bundler/provider that produces single-file worker output, independent of that.

The wasm bindings for resvg, satori and takumi created their init promise at
module-evaluation time. Bundlers that emit a single chunk for worker targets
(e.g. Nitro presets with inlineDynamicImports, used by netlify-edge and
other base-worker presets) hoist these modules into the entry, so the WASM
fetch and compilation ran on every isolate cold start, even for requests
that never render an OG image.

Expose initWasmPromise as a lazy memoized getter instead: the object shape
consumed by getResvg/getSatori/getTakumi is unchanged, but the init chain
only starts on first access, which happens inside the render path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sibh1BhDY97CPafumYmJyU
@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/nuxt-og-image@673

commit: 2b31128

@github-actions

Copy link
Copy Markdown

📦 Package Size

⚠️ 2 size metrics grew

Package output Gzipped Δ
nuxt-og-image · published payload 165 kB → 166 kB 🔴 +940 B (+0.6%)
nuxt-og-image · server runtime 64 kB → 65 kB 🔴 +940 B (+1.5%)
All tracked output (18)
Package output Gzipped Raw
nuxt-og-image · dependency @clack/prompts 12 kB 50 kB
nuxt-og-image · dependency @vue/compiler-sfc 574 kB 2.62 MB
nuxt-og-image · dependency chrome-launcher 21 kB 69 kB
nuxt-og-image · dependency lightningcss 1.4 kB 3.6 kB
nuxt-og-image · dependency magicast 72 kB 355 kB
nuxt-og-image · dependency mocked-exports 1.3 kB 2.6 kB
nuxt-og-image · dependency nuxt-site-config 9.8 kB 24 kB
nuxt-og-image · dependency nuxtseo-shared 21 kB 72 kB
nuxt-og-image · dependency oxc-parser 131 kB 1.38 MB
nuxt-og-image · dependency radix3 4.3 kB 16 kB
nuxt-og-image · dependency strip-literal 711 B 2.3 kB
nuxt-og-image · dependency tinyexec 4.1 kB 13 kB
nuxt-og-image · export . 373 B 900 B
nuxt-og-image · export ./content 324 B 607 B
nuxt-og-image · published payload 166 kB 574 kB 🔴
nuxt-og-image · app runtime 8.5 kB 26 kB
nuxt-og-image · server runtime 65 kB 178 kB 🔴
nuxt-og-image · shared runtime 3.1 kB 9.7 kB
Runtime dependencies (30)
Package Dependency Requested Resolved Cost
nuxt-og-image @clack/prompts ^1.7.0 1.7.0 📦 12 kB gzip
nuxt-og-image @nuxt/kit ^4.5.2 4.5.2 ♻️ free via Nuxt 4.5.2
nuxt-og-image @vue/compiler-sfc ^3.5.41 3.5.41 📦 574 kB gzip
nuxt-og-image chrome-launcher ^1.2.1 1.2.1 📦 21 kB gzip
nuxt-og-image consola ^3.4.2 3.4.2 ♻️ free via Nuxt 4.5.2
nuxt-og-image defu ^6.1.7 6.1.7 ♻️ free via Nuxt 4.5.2
nuxt-og-image devalue ^5.9.0 5.9.0 ♻️ free via Nuxt 4.5.2
nuxt-og-image exsolve ^1.1.1 1.1.1 ♻️ free via Nuxt 4.5.2
nuxt-og-image fnv1a-64 ^0.1.2 0.1.2 ♻️ free via Nuxt 4.5.2
nuxt-og-image lightningcss ^1.33.0 1.33.0 📦 1.4 kB gzip
nuxt-og-image magic-string ^1.1.0 1.1.0 ♻️ free via Nuxt 4.5.2
nuxt-og-image magicast ^0.5.4 0.5.4 📦 72 kB gzip
nuxt-og-image mocked-exports ^0.1.1 0.1.1 📦 1.3 kB gzip
nuxt-og-image nuxt-site-config ^4.2.0 4.2.0 📦 9.8 kB gzip
nuxt-og-image nuxtseo-shared ^5.3.12 5.3.12 📦 21 kB gzip
nuxt-og-image nypm ^0.6.9 0.6.9 ♻️ free via Nuxt 4.5.2
nuxt-og-image object-identity ^0.2.3 0.2.3 ♻️ free via Nuxt 4.5.2
nuxt-og-image ofetch ^1.5.1 1.5.1 ♻️ free via Nuxt 4.5.2
nuxt-og-image ohash ^2.0.11 2.0.11 ♻️ free via Nuxt 4.5.2
nuxt-og-image oxc-parser ^0.143.0 0.143.0 📦 131 kB gzip
nuxt-og-image oxc-walker ^1.1.1 1.1.1 ♻️ free via Nuxt 4.5.2
nuxt-og-image pathe ^2.0.3 2.0.3 ♻️ free via Nuxt 4.5.2
nuxt-og-image pkg-types ^2.3.1 2.3.1 ♻️ free via Nuxt 4.5.2
nuxt-og-image radix3 ^1.1.2 1.1.2 📦 4.3 kB gzip
nuxt-og-image std-env ^4.2.0 4.2.0 ♻️ free via Nuxt 4.5.2
nuxt-og-image strip-literal ^4.0.0 4.0.0 📦 711 B gzip
nuxt-og-image tinyexec ^1.3.0 1.3.0 📦 4.1 kB gzip
nuxt-og-image ufo ^1.6.4 1.6.4 ♻️ free via Nuxt 4.5.2
nuxt-og-image ultrahtml ^1.7.0 1.7.0 ♻️ free via Nuxt 4.5.2
nuxt-og-image unplugin ^3.3.0 3.3.0 ♻️ free via Nuxt 4.5.2

Baseline: main_@_f8308f5a___2026-08-11 · gzip is the comparison metric · changes below 16 B gzip are ignored

@harlan-zw

Copy link
Copy Markdown
Collaborator

I'm not quite sure if this is right; the module itself is only loaded within the route handler, which loads lazily. The issue is correctly upstream if Nitro is eagerly loading.

@JakeChampion

Copy link
Copy Markdown
Author

nitrojs/nitro#4525

@harlan-zw

Copy link
Copy Markdown
Collaborator

Yup I saw that, thanks. Will keep this open until that's merged and we can verify.

@harlan-zw

Copy link
Copy Markdown
Collaborator

🤖 REVIEW PAUSED

Harlan GitHub Agent posted this automated comment. AI open source policy.

This pull request is from an outside contributor. Add the harlan-agent-review label to approve automated review and verified repairs for head commit 2b3112831bc6.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants