Writing Python code in the browser, we constantly run into what I call the
sync/async problem. Namely, most Python code and most libc functions are
synchronous, but most JavaScript APIs are asynchronous. For example, urllib
offers a synchronous API to make a request, but in JavaScript, the normal way to
make a request is with the fetch()
API which is asynchronous. This made it very
difficult to make urllib
work in Pyodide. Python also has APIs like
event_loop.run_until_complete()
which are supposed to block until an async
task is completed. There was no way to do this in Pyodide.
JavaScript Promise Integration (JSPI) is a new web standard that solves the sync/async problem. It allows us to make a call that seems synchronous from the perspective of Python but is actually asynchronous from the perspective of JavaScript. In other words, you can have a blocking Python call without blocking the JavaScript main thread. JSPI enables this by stack switching.
Using JSPI it is finally possible to implement input()
, requests.get()
,
asynchronous file system backends, and many other synchronous Python APIs with
asynchronous implementations.
JSPI became a finished stage 4 proposal on April 8,
2025. Chrome 137, released May 27th, 2025,
supports JSPI.
This will make Pyodide work in many use cases that were previously awkward or
impossible. For instance, many Python educators are reluctant to introduce
async
/await
to beginning students and need input()
to work properly.
In this blog post, we explain how Pyodide’s stack switching API works. In followup posts we will discuss some technical details of the implementation.
Thanks to Antonio Cuni, Guy Bedford, and Gyeongjae Choi for feedback on drafts. Thanks also to my current employer Cloudflare and my former employer Anaconda for paying me to work on this.
Pyodide’s stack switching API
Pyodide defines a Python function run_sync()
which suspends execution until the
given awaitable is completed. This solves the sync/async problem.
For example, suppose you have Python code that makes an HTTP request using the
builtin urllib
library and you want to use it in Pyodide. The code might look
like this:
import urllib.request
def make_http_request(url):
with urllib.request.urlopen(url) as response:
return response.read().decode('utf-8')
def do_something_with_request(url):
result = make_http_request(url)
do_something_with(result)
The native implementation of urllib
requires low-level socket operations which
are not available in the browser. To make urllib
work in Pyodide, we need to
implement it based on the fetch()
browser API. However, fetch()
is asynchronous
which means we cannot directly use it in a synchronous Python function.
from js import fetch
async def async_http_request(url):
resp = await fetch(url)
return await resp.text()
# Problem: we need to make this function async
async def do_something_with_request(url):
result = await async_http_request(url)
do_something_with(result)
This is the typical sync/async problem: when you introduce an asynchronous API call, you need to change the code all the way up the call stack to be asynchronous as well.
Pyodide’s run_sync()
function helps with this. It allows us to call asynchronous
functions synchronously from Python code.
from pyodide.ffi import run_sync
from js import fetch
async def async_http_request(url):
resp = await fetch(url)
return await resp.text()
def make_http_request(url):
# make_http_request is a synchronous function that will block until
# async_http_request() completes
return run_sync(async_http_request(url))
# Use make_http_request in a non-async function 🎉
def do_something_with_request(url):
result = make_http_request(url)
do_something_with(result)
Here, run_sync()
blocks until the awaitable resolves then synchronously
returns the result. This allows us to port synchronous code to Pyodide without
changing the entire codebase to be asynchronous.
This run_sync()
function is integrated in the Pyodide’s event loop since Pyodide
version 0.27.7. If your browser supports JSPI, both asyncio.run()
and
event_loop.run_until_complete()
will use stack switching to run the async
task.
The builtin urllib
library still does not work in Pyodide but urllib3
supports Pyodide and will use stack switching if it is possible thanks to
work contributed by Joe Marshall.
This also means we fully support requests
since it is a dependency of
urllib3
.
When can we use run_sync()
?
run_sync()
works only if the JavaScript runtime supports JSPI and Javascript
code calls into Python in an asynchronous way. Whenever a Promise
is returned
to JavaScript, stack switching will be enabled. If the return value is not a
Promise
, stack switching will be disabled. Specifically, stack switching is
enabled when:
- we call
pyodide.runPythonAsync()
, - we call an asynchronous Python function, or
- we call a synchronous Python function with
callPromising()
.
Stack switching is disabled when:
- we call
pyodide.runPython()
or - we call a synchronous Python function directly.
It is possible to query whether or not stack switching is enabled with
pyodide.ffi.can_run_sync()
.
For example, if you call do_something_with_request()
from JavaScript without
using callPromising()
:
do_something_with_request = pyodide.globals.get("do_something_with_request");
do_something_with_request("https://example.com");
It will raise:
RuntimeError: Cannot stack switch because the Python entrypoint was a synchronous
function. Use pyFunc.callPromising() to fix.
You need to call it like
do_something_with_request.callPromising("https://example.com")
This returns a Javascript promise, even though a synchronous Python function would ordinarily return the value directly.
Executing Python code that uses run_sync()
requires using
pyodide.runPythonAsync()
instead of pyodide.runPython()
:
pyodide.runPython("run_sync(asyncio.sleep(1))"); // RuntimeError: Cannot stack switch ...
pyodide.runPythonAsync("run_sync(asyncio.sleep(1))"); // Works fine
Conclusion
JSPI finally lets us run synchronous Python code that consumes asynchronous
JavaScript APIs. Pyodide 0.27.7 fully supports JSPI in Chrome 137, in Node 24
with the --experimental-wasm-jspi
flag, and in Firefox with the
javascript.options.wasm_js_promise_integration
flag. There will soon be a
version of Cloudflare Python workers that supports JSPI as well.
We believe this is going to open up so many new possibilities for Pyodide. We are so excited to see what people do with it.