Running code in the browser without a server

A code playground looks like an editor with a Run button. Underneath, there are two very different execution models: JavaScript is already running in the page, while Python has to be compiled to WebAssembly and shipped to the tab. Both can stay entirely client-side — but the isolation, the download cost and the failure modes are not the same.

Two execution models

A browser already has a JavaScript engine. Running a snippet of JavaScript does not require a server, a virtual machine or a new runtime: the page can hand the source to the same engine that runs its own scripts. HTML and CSS are even simpler — the browser's parser and style engine are the implementation. That is why an HTML, CSS and JavaScript playground can be a genuinely local tool with no backend at all.

Python is different. Browsers do not ship a Python interpreter, so one has to be brought along. Pyodide is CPython compiled to WebAssembly: a real Python 3 interpreter, including much of the standard library, running inside the browser's Wasm sandbox. It is not a JavaScript re-implementation and it is not a remote shell. The files are static assets, and the code executes in the tab. That is what makes an online Python playground possible without sending source code to a server.

The same model powers the site's JavaScript, TypeScript and Lua playgrounds. JavaScript runs directly in a Worker; TypeScript is stripped to JavaScript by Sucrase first; Lua is run by the Fengari VM. Each page keeps execution client-side, and the Worker makes Stop able to end a loop that never yields.

The iframe is the boundary

“Runs in the browser” is not, by itself, a security model. A page that pasted user HTML directly into its own document would let that HTML read the page's cookies, call its APIs and change its interface. A playground needs a real boundary, and the browser already provides one: the iframe sandbox.

Both playgrounds put the executing document in an iframe whose sandbox attribute lists only the capabilities the preview needs. The HTML playground uses allow-scripts allow-forms allow-modals allow-popups; the Python runner uses only allow-scripts. Crucially, neither includes allow-same-origin. Without that flag the iframe has an opaque origin: its scripts cannot read the parent page's DOM, cookies or localStorage, and they are treated as a different, untrusted site for storage and cross-origin rules.

Communication back to the editor uses postMessage, not direct function calls. The HTML playground tags every message with a random token generated for that preview, and the parent ignores anything whose source is not the iframe or whose token does not match. The Python runner uses a fixed channel name and the parent checks the message source. This is a narrow, one-way reporting channel: console lines, stdout, stderr and a small status vocabulary.

Assembling an HTML document

The HTML playground has three editors, but a browser needs one document. The page combines them before assigning the result to the iframe's srcdoc. For a fragment, the markup becomes the body, the CSS is placed in a <style> element in the head, and the JavaScript is placed in a <script> element at the end of the body, after the markup has been parsed. For a full document — one that starts with a doctype or an <html> element — the playground keeps the document and injects into it instead: CSS and the console bridge at the top of the head, JavaScript before the closing body tag.

The injection step has a sharp edge that is easy to miss. If user JavaScript contains the literal text </script>, dropping it straight into a script element would end that element early and turn the rest of the snippet into markup. The composer escapes the closing sequence as <\/script>, which is still a slash inside a JavaScript string, template, regex or comment, so the code keeps its meaning while the HTML parser no longer sees a closing tag. The same treatment applies to </style> inside CSS. These cases are unit-tested because they are exactly the kind of bug that looks harmless until someone pastes a string containing a closing tag.

The console bridge is injected before the user's markup so inline scripts in the HTML pane are captured too. It wraps console.log, info, warn and error, listens for window errors and unhandled promise rejections, and forwards the text to the editor. The panel keeps the last 200 lines; a loop that logs on every animation frame cannot grow the DOM without bound.

Loading Python without surprising the user

Pyodide is large. The pinned runtime used by the Python playground is Pyodide 314.0.7, built from CPython 3.14.2, and the first load downloads roughly 13 MB of loader, WebAssembly module and standard-library archive. That is a real cost on a slow or metered connection, so the page does not hide it: the first Run explains the download, and a second click starts it. The page then stores the pinned core files in the browser’s Cache Storage, so later visits normally reuse them without downloading again. A private window or cleared site data starts from scratch.

The interpreter itself runs in a Worker inside a separate sandboxed iframe. That design choice is what makes Stop real: a synchronous Python loop does not yield to the browser's event loop, so a timer or a flag inside the same thread cannot interrupt it. The parent page can, however, discard the whole iframe and terminate the Worker inside it. Stopping therefore resets the interpreter — user globals are lost — and the next run loads the runtime again.

Python has no terminal in a Wasm sandbox, so the page supplies standard input itself. Lines from the input box are passed as a Pyodide global, and a small prelude replaces the builtin input() with a reader over those lines. The text is never interpolated into the user's source; a line containing quotes or Python syntax remains a string. When the lines run out, input() raises EOFError, matching a script reading an empty stream.

One consequence of reusing the interpreter is that state persists between runs. A function defined in one run is available in the next, which is convenient for iterative work. The input reader, by contrast, is rebuilt from the box on every run. When a clean namespace is what you need, Stop is the reliable reset.

What the sandbox does not stop

A sandbox is an isolation boundary, not a content filter. Code inside the preview can still make network requests to origins that allow them, load remote images and fonts, and consume CPU and memory until the iframe is replaced or the tab is closed. A long-running Python loop can keep a core busy; JavaScript can allocate until the tab is under pressure. The parent page stays responsive because it is a different document, but the machine is still doing the work.

Cross-origin requests remain subject to CORS. The preview's opaque origin does not grant any special access; if an API does not send Access-Control-Allow-Origin, the browser blocks the response. Browser storage is similarly restricted: code in an opaque-origin iframe generally cannot use localStorage or cookies, which is why both playgrounds tell you to keep state in normal variables. And a sandbox cannot make it safe to paste production secrets into a page you do not control. The tools do not upload your source, but the code you run can still leak a secret through a request you wrote.

Network requests and CORS

Python's requests, urllib and urllib3 expect a socket. A browser tab does not expose one, so the Python playground loads pyodide-http and rewrites those libraries to use fetch. The library API survives, but the browser's CORS rule does not go away: the server must send Access-Control-Allow-Origin for the response to be readable. A call to a normal website can therefore fail with NetworkError even though the package is installed and the URL is correct. That is the browser protecting the server, not a broken wheel.

Use an API that explicitly allows cross-origin reads. The playground's HTTP request examples use JSONPlaceholder for a simple GET, Open-Meteo for query parameters, httpbin for a JSON POST, DummyJSON for another GET, urllib3 for a different patched client and pyfetch for an async browser-native call. A site such as baidu.com does not send the header, so it is included as a deliberate failure example. There is no browser setting that makes the request work from this page, and no public proxy is provided because a proxy would see the request and response. A small proxy you control, or a local Python process, is the appropriate solution outside the sandbox. Clients that bring their own socket-based transport, such as httpx or aiohttp, are not guaranteed to work in a browser page even when they import.

When a playground is the right tool

Use a playground for reduction and explanation: reproduce a bug in ten lines, check what a DOM API returns, try a CSS layout, demonstrate an exception, or confirm a slice before you put it in a codebase. The immediate feedback and the absence of setup are the point. The HTML playground also downloads a standalone copy of the page, which is a convenient starting point for a static demo.

Use a local environment for anything with tests, persistent files, a debugger or real data. The Python playground can load Pyodide-built packages and pure-Python wheels from PyPI, but there is no persistent environment, no pinned project lockfile and no way to keep installed packages after Stop. The HTML playground has no bundler and no package resolution for bare imports. Those are not temporary limitations to work around; they are the reason the tools can stay static, private and cheap to run.

Why keep the server out of it

A server-side runner would make heavier environments possible, but it would change the privacy claim, the operating cost and the abuse surface all at once. Every snippet would become data on someone else's machine; the service would need authentication, quotas, isolation and cleanup; and a public code-execution endpoint is a permanent security target. Keeping execution in the browser avoids all three. The trade-off is the one described above: the runtime must be downloaded to the user, and the available packages are limited to what can be shipped as Wasm.

For this site's purpose — a scratchpad that opens instantly and asks for no account — that is the right trade. The code stays where it was typed, and the only network request the tool makes on its own is the download of the pinned runtime.

Questions this guide raises

Is the code I paste uploaded to the site?

No. Both playgrounds execute in the browser. The source is passed to a sandboxed iframe on the same page, and the page has no endpoint that accepts code. The Python runtime is downloaded from jsDelivr, which sees the request for the runtime files but not the snippet.

Can the preview read the rest of the website?

No. The iframe is sandboxed without allow-same-origin, so it has an opaque origin and cannot read the parent page’s cookies, localStorage or DOM. It reports back only through postMessage.

Why is Python a 13 MB download when JavaScript is instant?

The browser already contains a JavaScript engine, so there is nothing extra to fetch. Python is not built in; Pyodide ships CPython compiled to WebAssembly plus a standard-library archive. That is a one-time download; the page keeps the pinned core files in Cache Storage so later visits normally do not fetch them again.

Can I install third-party Python packages?

Yes. Add package names in the Dependencies panel and press Install dependencies; the panel shows queued, installing, ready or failed for each package, the version, import name and known dependencies. Packages built for Pyodide — requests, numpy, pandas and many others — load from the pinned runtime CDN; other pure-Python wheels are installed from PyPI with micropip. Native extensions without a WebAssembly build cannot load, and packages live in the sandbox’s memory until Stop or reload. A local environment is still the right tool for a persistent project with pinned dependencies.

Does requests work in the Python playground?

Yes. The runner automatically loads pyodide-http when requests, urllib or urllib3 is imported, and patches requests to use the browser’s fetch API. CORS still applies: APIs that send Access-Control-Allow-Origin work, while ordinary websites that do not allow cross-origin requests are blocked. That is a browser boundary, not a missing package.

Can requests fetch any URL?

No. The library is patched to use fetch, and the browser still enforces CORS. APIs that send Access-Control-Allow-Origin work; ordinary sites such as baidu.com do not. The playground includes CORS-enabled examples and explains why a cross-origin failure is not a missing package.

Does Stop really kill an infinite loop?

Yes. The interpreter runs in a Worker inside a sandboxed iframe, and Stop discards the iframe, which terminates the Worker and the computation. The next run starts a fresh interpreter, so variables from the stopped session are gone.