Skip to content

fix(http-client-python): boot pyodide lazily in the browser - #11507

Open
timotheeguerin wants to merge 1 commit into
microsoft:mainfrom
timotheeguerin:fix/lazy-browser-pyodide
Open

fix(http-client-python): boot pyodide lazily in the browser#11507
timotheeguerin wants to merge 1 commit into
microsoft:mainfrom
timotheeguerin:fix/lazy-browser-pyodide

Conversation

@timotheeguerin

Copy link
Copy Markdown
Member

Fixes #11506

Problem

The browser Pyodide bootstrap ran as a module-level side effect:

const browserPyodidePromise: Promise<PyodideInterface> | null =
  typeof window !== "undefined" ? setupPyodideCallBrowser() : null;

So merely importing @typespec/http-client-python downloaded a full CPython WebAssembly runtime (pyodide.asm.wasm, python_stdlib.zip) and micropip-installed jinja2, black, setuptools, … live from PyPI.

The playground imports every library in its import map up front (loadLibraries in packages/playground/src/browser-host.ts), so this happened on every single page load, whether or not the user ever selected the Python emitter.

Impact

Measured against the live typespec.io/playground with Playwright WebKit under iPhone 15 emulation:

Scenario Peak WebContent RSS
typespec.io homepage 195 MB
Playground, python/csharp emitters blocked 463 MB
Playground as shipped 752 MB

Total page weight was 22.9 MB across 197 requests, of which ~8 MB was Pyodide + PyPI wheels.

~750 MB is past what iOS Safari allows a single tab, so the WebContent process gets killed and reloaded — which is exactly the "loads infinitely" symptom in the issue.

Fix

Boot Pyodide on the first browser emit instead of at import time. The cached promise is dropped if the bootstrap rejects, so a later emit can retry after a transient failure (matching the existing behaviour in pyodide-loader.browser.ts).

No behaviour change for the Node path, and no change for users who actually select the Python emitter beyond moving the download to the point where it's needed.

Testing

Added emitter/test/browser-pyodide.test.ts, which stubs window and asserts that importing the emitter does not call loadPyodide. Verified it fails against the previous implementation and passes with this change.

✓ emitter/test/browser-pyodide.test.ts (1 test)
✓ emitter/test/external-process.test.ts (2 tests)
✓ emitter/test/utils.test.ts (3 tests)

Follow-ups (not in this PR)

  • Monaco's web workers fail to be created on typespec.io (Could not create web worker(s). Falling back to loading web worker code in main thread) on desktop and mobile alike, so compilation runs on the main thread. Painful on a phone CPU.
  • The playground eagerly imports every emitter module; deferring emitter loading until selection would make it resilient to this class of regression.

The browser bootstrap ran as a module-level side effect, so simply importing
the emitter downloaded a full CPython WebAssembly runtime and micropip-installed
its wheels from PyPI.

Hosts like the TypeSpec playground import every library in the import map up
front, so this happened on every page load. Measured on typespec.io/playground
(WebKit, iPhone emulation) it added ~290MB of resident memory (463MB -> 752MB)
and ~8MB of downloads, pushing the tab past the per-tab memory budget on mobile
browsers and preventing the playground from loading.

Boot pyodide on the first emit instead, and drop the cached promise when the
bootstrap fails so a later emit can retry.

Fixes microsoft#11506
@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:client:python Issue for the Python client emitter: @typespec/http-client-python label Jul 31, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/http-client-python@11507

commit: 2a660d3

@github-actions

Copy link
Copy Markdown
Contributor

All changed packages have been documented.

  • @typespec/http-client-python
Show changes

@typespec/http-client-python - fix ✏️

Only boot the Pyodide runtime in the browser on the first emit instead of when the emitter module is imported. Hosts such as the TypeSpec playground import every available emitter up front, so the eager bootstrap downloaded a full CPython WebAssembly runtime and its wheels on every page load, which prevented the page from loading on mobile browsers.

@azure-sdk-automation

azure-sdk-automation Bot commented Jul 31, 2026

Copy link
Copy Markdown

You can try these changes here

🛝 Playground 🌐 Website 🛝 VSCode Extension

@timotheeguerin
timotheeguerin marked this pull request as ready for review July 31, 2026 14:26
github-merge-queue Bot pushed a commit that referenced this pull request Jul 31, 2026
Fixes #11506

## Problem

`typespec.io/playground` hangs forever on iOS. The standalone playground
(`cadlplayground.z22.web.core.windows.net`) works fine.

The difference:
`website/src/components/playground-component/import-map.ts` adds
`@typespec/http-client-python` and `@typespec/http-client-csharp` to the
website's import map, and `browser-host.ts` eagerly `import()`s
**every** library in the map at startup — before the user has picked an
emitter.

`@typespec/http-client-python` boots a full Pyodide (CPython/WASM)
runtime as a module-level side effect, which alone pulls ~23 MB of extra
payload and ~270 MB of resident memory.

Measured with Playwright WebKit under iPhone 15 emulation (`ps` RSS of
the `WebKit.WebContent` process):

| Page | RSS |
| --- | --- |
| typespec.io home | 195 MB |
| standalone playground (works on real iPhone) | 547 MB |
| typespec.io/playground?version=1.13.x (no extra emitters — **works on
real iPhone**) | 463 MB |
| typespec.io/playground (as shipped — **hangs on real iPhone**) | 741
MB |

## Fix

Add a `deferredEmitters` option. Libraries named there are registered as
placeholder entries at startup (so they still show up in the emitter
dropdown) and are only imported when a compilation actually needs them —
i.e. when they're the selected emitter or listed under `emit` in the
tspconfig.

The website passes the two heavy client emitters. Nothing is downloaded
or evaluated for them until you select one.

Caveat, documented on the option: a deferred library can't be referenced
by an `import` statement in TypeSpec source. That's fine for these two —
they're pure emitters with no decorators or `.tsp` surface.

## Relation to #11507

#11507 makes the Pyodide boot
lazy inside the Python emitter. That's the right fix at the source, but
the website resolves emitter bundles at **runtime** from blob storage
(`typespec.blob.core.windows.net/pkgs/<name>/latest.json`), uploaded by
a separate ADO stage. So it can't take effect — or be validated in a PR
preview — until the emitter is republished. This PR fixes the site
independently of that, and also cuts startup cost for the C# emitter.

## Tests

- `packages/playground/test/browser-host-deferred.test.ts` — deferral,
exclusion from the virtual `package.json` dependencies, load-once
memoization, retry after a failed load, no-op for non-deferred libs.
- `packages/playground/test/deferred-emitter-compile.test.ts` —
end-to-end against the **real** compiler: compiling with a deferred
emitter fails to resolve it, then succeeds and writes its output after
`loadLibrary()`.

Full playground suite: 55/55 green. `tsc --noEmit` clean for
`packages/playground` and `website/src`.

## Not fixed here

Monaco web workers fail to start on typespec.io on desktop *and* mobile
(`Could not create web worker(s). Falling back to loading web worker
code in main thread`) — `MonacoEnvironment.getWorker` isn't defined.
Pre-existing and unrelated to the hang, but worth a follow-up:
everything currently runs on the main thread.

## Why this is an opt-in list rather than "defer every emitter"

`isEmitter` comes from `$lib.emitter`, which only exists once the bundle
has been evaluated. Neither the blob index (`latest.json` is just
`{version, imports}`) nor `package.json` carries an emitter marker, so
there is no way to populate the emitter dropdown without importing every
candidate. Deferring everything would leave the dropdown empty.

Deferring *all* emitters would also break the ones that ship TypeSpec
surface — `@typespec/openapi3` (`lib/decorators.tsp`),
`@typespec/json-schema`, `@typespec/protobuf` — since an `import
"@typespec/json-schema";` in the editor would fail to resolve before the
emitter is ever selected. `http-client-python` and `http-client-csharp`
are the only two in the map that are pure emitters with no `.tsp`.

The payoff is also concentrated in those two: with pyodide blocked the
page sits at 474 MB vs 463 MB for `?version=1.13.x` (which loads no
additional packages at all), so everything else the website pulls in
eagerly — including the ~7 Azure libraries that `http-client-python`'s
import map drags along — is only ~11 MB combined.

Possible follow-up, not done here: the host's `readFile`/`stat` are
async, so a miss under `node_modules/<deferred-lib>/` could trigger the
load mid-resolution. That would remove the "cannot be referenced by an
`import` statement" restriction and make the list safe to expand without
auditing each library.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:client:python Issue for the Python client emitter: @typespec/http-client-python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Playground doesn't seem to load on mobile

1 participant