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:

  1. we call pyodide.runPythonAsync(),
  2. we call an asynchronous Python function, or
  3. we call a synchronous Python function with callPromising().

Stack switching is disabled when:

  1. we call pyodide.runPython() or
  2. 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.