Skip to content

fix(og): lazy-load @vercel/og to keep it out of the main worker entry - #1774

Merged
james-elicx merged 3 commits into
mainfrom
fix/og-lazy-import
Jun 5, 2026
Merged

fix(og): lazy-load @vercel/og to keep it out of the main worker entry#1774
james-elicx merged 3 commits into
mainfrom
fix/og-lazy-import

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Problem

The next/og shim (packages/vinext/src/shims/og.tsx) statically imported @vercel/og. As a result, the idiomatic Next.js usage

import { ImageResponse } from "next/og";

at the top of a route module pulled the entire ~800 KB @vercel/og runtime (satori + resvg + embedded wasm + base64-inlined fonts) into the always-loaded server entry (dist/server/index.js), where it is parsed on every cold start — even for requests that never generate an OG image.

Apps could only avoid this by manually writing await import("next/og") (as examples/app-router-playground does, with a code comment explaining the workaround). The framework shouldn't require that.

Fix

Import @vercel/og via a dynamic import() inside the shim's already-async ReadableStream.start() callback. The heavy runtime is now always emitted as its own code-split chunk, regardless of whether the app imports next/og statically or dynamically. Type-only imports stay static (erased at build, no runtime pull-in).

Impact

On examples/app-router-cloudflare (which uses a static import { ImageResponse } from "next/og"), dist/server/index.js drops from ~1.67 MB to ~875 KB. satori/resvg move into a separate chunk; the og chunk still contains the base64-inlined font, so OG generation still works. The already-dynamic app-router-playground case is unaffected.

Tests

Verified locally with targeted runs:

  • tests/og-inline.test.ts, tests/og-font-patch.test.ts, tests/shims.test.ts — 1040 passed
  • tests/build-optimization.test.ts — 82 passed

The next/og shim statically imported @vercel/og, so a top-level
`import { ImageResponse } from "next/og"` inlined ~800 KB of satori + resvg +
wasm/fonts into the always-loaded server entry. Import it via dynamic import()
inside the async stream callback so it is always a separate chunk.

app-router-cloudflare dist/server/index.js: ~1.67 MB -> ~875 KB.
@pkg-pr-new

pkg-pr-new Bot commented Jun 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1774
npm i https://pkg.pr.new/vinext@1774

commit: bfee2e2

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: lazy-load @vercel/og to keep it out of the main worker entry

Verdict: LGTM. Clean, minimal, well-targeted change. Verified the fix works end-to-end on the PR preview deployment.

What I verified

  • No behavioral regression in error handling. On main, new VercelImageResponse(...) was already inside the async ReadableStream.start() callback. This PR only adds await import("@vercel/og") immediately before that existing call. So error surfacing semantics are unchanged — both before and after, a failure in @vercel/og (construction or now also the dynamic import) propagates as a stream error during body consumption, not at new ImageResponse() construction. Good.
  • No resolveId loop. The resolveId hook at packages/vinext/src/index.ts:2342 redirects @vercel/og imports to the shim unless the importer is the shim itself (isVinextOgShimImporter, index.ts:308). The dynamic import("@vercel/og") inside og.tsx correctly resolves to the real package, not back to the shim. Confirmed og.tsx is matched by the importer check.
  • optimizeDeps.exclude already lists @vercel/og (build-optimization.test.ts:160), so it isn't pre-bundled and the dynamic import code-splits cleanly into its own chunk.
  • No per-request overhead. ESM caches the module, so import("@vercel/og") only pays the load cost once per isolate, then returns cached.
  • End-to-end on the preview deploy: GET https://pr-1774-app-router-cloudflare.vinext.workers.dev/api/og?title=test200, content-type: image/png, ~64 KB PNG. OG generation still works with the static import { ImageResponse } from "next/og" and lazy runtime load (workerd WASM path + base64-inlined font intact).
  • Tests pass: tests/og-inline.test.ts, tests/og-font-patch.test.ts, tests/shims.test.ts (1040 passed), tests/build-optimization.test.ts (82 passed). vp check clean on the changed file.

Minor follow-ups (non-blocking)

  1. vinext:og-assets safety net may now be skipped on the Node.js target. That plugin (packages/vinext/src/plugins/og-assets.ts:183-192) only scans <rsc-out>/index.js for the resvg.wasm string before copying the WASM file. Now that @vercel/og is code-split into a separate chunk, the resvg.wasm reference may no longer appear in index.js, so content.includes("resvg.wasm") could return false and the disk-read fallback copy would be skipped. This only affects the Node.js fallback path — on workerd (the primary target) resvg.wasm is loaded via ?module and bundled by Wrangler, which is exactly why the preview deploy above works. Worth a quick check that the scan covers all emitted chunks (or scans the chunk that actually references the asset), if the Node.js target is still supported for OG.

  2. Dead workaround left in an example. The PR's premise is that apps no longer need to manually write await import("next/og"). examples/app-router-playground/app/api/og/route.tsx:6-8 still uses that workaround with a now-stale comment. Not in scope for this PR, but a candidate cleanup to demonstrate the fix.

Neither item blocks merge.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1774.

Summary

Verdict: LGTM — clean, minimal, well-targeted change with no behavioral regression.

Key findings

The fix is correct. The change converts a static import of @vercel/og into a dynamic await import() inside the shim's already-async ReadableStream.start() callback. I verified:

  • No error-handling regressionnew VercelImageResponse(...) was already inside the async start() on main, so error surfacing semantics are unchanged.
  • No resolveId loop — the importer-aware guard at index.ts:2342/:308 ensures the dynamic import resolves to the real package, not back to the shim.
  • Code-splitting works@vercel/og is already in optimizeDeps.exclude, and the dynamic import splits it into its own chunk.
  • End-to-end validationGET /api/og on the PR preview returns 200 + image/png (~64 KB), confirming OG generation still works with the static import + lazy runtime load.
  • Tests pass — og-inline, og-font-patch, shims (1040), build-optimization (82); vp check clean.

Two non-blocking follow-ups I flagged

  1. The vinext:og-assets safety-net plugin only scans index.js for the resvg.wasm string; now that the package is in a separate chunk, the Node.js disk-read fallback copy may be skipped (workerd is unaffected, which is why the preview works).
  2. The app-router-playground example still carries the now-unnecessary await import("next/og") workaround — a candidate cleanup since this PR removes the need for it.

github run

Address review feedback on the lazy @vercel/og change:

- vinext:og-assets scanned only index.js for the resvg.wasm reference to
  decide whether to copy the Node.js disk-read fallback asset. Now that
  @vercel/og is code-split into its own chunk, that reference is no longer in
  index.js, so the copy was skipped (breaking the Node target's OG fallback).
  Scan all emitted chunks via the writeBundle bundle arg instead.
- app-router-playground used a manual `await import("next/og")` workaround
  that is no longer needed; switch it to the idiomatic static import.
@james-elicx

Copy link
Copy Markdown
Member Author

Thanks @ask-bonk — addressed both follow-ups in bfee2e2b:

  1. vinext:og-assets resvg.wasm copy — confirmed the regression: with @vercel/og now code-split, resvg.wasm no longer appears in index.js, so the safety-net copy was skipped. The plugin now scans all emitted chunks via the writeBundle bundle arg instead of only index.js. Verified dist/server/resvg.wasm is copied again on the app-router-cloudflare build (was missing before the fix), with og still code-split (index.js satori/Resvg count = 0).

  2. Dead workaround in app-router-playground — switched app/api/og/route.tsx back to the idiomatic static import { ImageResponse } from "next/og" and removed the stale comment. Verified the build still code-splits og (satori/Resvg in index.js = 0) and the resvg.wasm copy fires.

Targeted tests still green: og-inline, og-font-patch, shims, build-optimization (1122 passed).

/bigbonk review for issues

@cloudflare cloudflare deleted a comment from ask-bonk Bot Jun 5, 2026
@cloudflare cloudflare deleted a comment from ask-bonk Bot Jun 5, 2026

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of bfee2e2b — both follow-ups addressed

I re-verified both fixes end-to-end (built app-router-cloudflare and app-router-playground from this branch, ran targeted tests, vp check). LGTM — both follow-ups are correctly resolved.

1. vinext:og-assets resvg.wasm copy — fix is correct and necessary

Reproduced the regression and confirmed the fix:

  • Built app-router-cloudflare on this branch. dist/server/index.js (the only thing the old code scanned) contains 0 references to resvg.wasm and 0 satori/Resvg references — so the pre-fix content.includes("resvg.wasm") check on index.js would have returned false and skipped the copy. Regression confirmed.
  • The og runtime is now code-split into dist/server/_next/static/index.edge-*.js, which is where the resvg.wasm literal lives (as new URL("./resvg.wasm", import.meta.url) injected by vinext:og-font-patch).
  • The new writeBundle(options, bundle) scan over all type === "chunk" outputs correctly finds that nested chunk, and dist/server/resvg.wasm (1378357 bytes) is copied again. Verified present on disk after build.

One robustness note worth calling out (in the fix's favor): the scan is reliable specifically because vinext:og-font-patch (index.ts:4741-4749) always injects the new URL("./resvg.wasm", import.meta.url) string literal into the Node.js catch fallback. So even though the bundler may rewrite the import("./resvg.wasm?module") specifier, the literal resvg.wasm string is guaranteed to survive in the chunk. Good — the two transforms are consistent.

2. Dead workaround in app-router-playground — clean

app/api/og/route.tsx now uses idiomatic import { ImageResponse } from "next/og" with the stale comment removed. Built it on this branch:

  • index.js satori/Resvg count = 0 (still code-split via the shim's lazy import)
  • dist/server/resvg.wasm copied

This is a good demonstration of the PR's premise (apps no longer need the manual await import("next/og")).

Verification summary

  • vp test run tests/og-font-patch.test.ts tests/og-inline.test.ts → 19 passed
  • vp test run tests/shims.test.ts tests/build-optimization.test.ts → 1103 passed
  • vp check on both changed source files → clean (format + lint + types)

Non-blocking follow-up

There is still no unit test for the vinext:og-assets writeBundle handler (neither before nor after this PR). This regression shipped silently precisely because the chunk-scanning logic is untested — the only thing catching it is a manual example build. Per AGENTS.md ("If you move behavior out of a template string / change runtime behavior, add a focused unit test"), a small test that feeds a synthetic bundle (a chunk whose code references resvg.wasm under a non-index.js filename) and asserts referencedAssets resolves would lock in this fix and prevent a future "scan only index.js" style regression. Not blocking this PR.

@ask-bonk

ask-bonk Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1774.

Summary

Verdict: LGTM — both follow-ups in bfee2e2b are correctly resolved. I verified everything end-to-end by building both examples from this branch.

1. vinext:og-assets resvg.wasm copy — fix confirmed

  • Regression reproduced: Built app-router-cloudflare on this branch. dist/server/index.js (all the old code scanned) has 0 resvg.wasm references and 0 satori/Resvg — so the old index.js-only scan would have skipped the copy.
  • Fix works: The og runtime now code-splits into dist/server/_next/static/index.edge-*.js, where the resvg.wasm literal lives. The new all-chunk writeBundle(options, bundle) scan finds it and dist/server/resvg.wasm (1.37 MB) is copied again.
  • Robustness note in the fix's favor: the literal resvg.wasm is guaranteed to survive in the chunk because vinext:og-font-patch always injects new URL("./resvg.wasm", import.meta.url) into the Node.js fallback — the two transforms stay consistent.

2. Dead workaround removed — clean

app-router-playground/app/api/og/route.tsx now uses idiomatic static import { ImageResponse } from "next/og", stale comment gone. Build still code-splits (satori/Resvg in index.js = 0) and copies the wasm.

Tests / checks (all green locally)

  • og-font-patch + og-inline → 19 passed
  • shims + build-optimization → 1103 passed
  • vp check on both changed source files → clean

Non-blocking follow-up flagged

There is still no unit test for the og-assets writeBundle handler. This regression shipped silently because the chunk-scanning logic is untested. A small test feeding a synthetic bundle (chunk referencing resvg.wasm under a non-index.js name) would lock in the fix per AGENTS.md guidance. Not blocking.

Build artifacts I generated were cleaned up; the working tree is clean.

github run

@james-elicx
james-elicx merged commit e40870e into main Jun 5, 2026
40 checks passed
@james-elicx
james-elicx deleted the fix/og-lazy-import branch June 5, 2026 22:23
@github-actions github-actions Bot mentioned this pull request Jun 5, 2026
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.

1 participant