cf.completefrontend
My progress
SYSTEM DESIGN · #108

Design an online code playground like CodePen

A browser playground where people write HTML, CSS, and JavaScript in three editors and see the result run live beside them, then save, fork, share, and embed it. The hard parts are running strangers' code without letting it touch the app or its users, turning keystrokes into a fresh preview fast without freezing anything, catching infinite loops, and piping the preview's console and errors back to the editor.

The question

Design the web client for an online code playground like CodePen. A pen has an HTML, a CSS, and a JavaScript panel. As you type, a preview next to the editors shows the result, and a console shows what the code logs and any errors it throws. People can use Sass or TypeScript instead of plain CSS and JavaScript, and import packages from npm.

Pens are public by default: anyone can open one, run it, fork it, and embed it on their own blog. Walk me through how you run code that strangers wrote without putting our users at risk, how a keystroke becomes an updated preview, how you stop an infinite loop from freezing the page, and how saving, forking, and embeds work.

Real-life examples: CodePen, JSFiddle, CodeSandbox, StackBlitz, JS Bin

Requirements: scoping a code playground

A code playground looks like three text boxes and an iframe. The trap is to spend the interview on the text boxes. The editor is a solved part you can buy off the shelf.

The interesting part is the iframe: it runs code written by someone you have never met, on your domain's doorstep, inside the browser tab of a logged-in user who just clicked a link. Every scope question should tell you something about that iframe: what may run in it, how fast it must update, and where it may appear.

Ask first who writes the code and who runs it. On a public playground the answer is the worst case: the author is anyone, and the person running it is anyone else who opened a link. That one answer makes security the first design topic, not the last. Then ask how live the preview must be, which languages are in scope, and whether pens can be embedded on other sites, because each answer moves a part of the design.

QuestionAnswer we assumeWhat it changes
Who writes the code, and who runs it?Anyone can publish a pen; anyone can open it. Opening a link runs the code at once.The preview must be treated as hostile: separate site, sandboxed iframe, no access to our cookies or tokens.
How live is the preview?It updates about half a second after you stop typing, plus a Run button. CSS edits should not restart the page.A debounced run pipeline, and a fast path for CSS that does not reload the iframe.
Which languages?HTML, CSS, JavaScript, plus Sass, TypeScript, JSX, and Markdown as options.Compilers loaded on demand and run in a web worker, plus source maps for errors.
Can code import packages?Yes, by name from npm, like import confetti from "canvas-confetti".Import maps and an ESM CDN (a CDN that serves npm packages as ES modules), or a bundler running in the browser.
Do we run Node or a backend?No. Pens run in the browser only. Full Node projects are out of scope.No server containers; we mention WebContainers only as a follow-up.
Where can a pen appear?On our site, in fullscreen, and embedded on other sites through an iframe snippet.The preview origin must be framable everywhere while the editor must not be.
How big is a pen?Usually under 50 KB of code per panel; we cap a panel at 1 MB.Whole-document saves are fine; no need for chunked storage.
Do people edit together?A paid pair mode for 2 to 6 people. Most sessions are solo.A collaboration layer that we keep small and reuse from other designs.

Functional requirements

  • Edit HTML, CSS, and JavaScript in three editor panels with syntax highlighting, autocomplete, and Emmet-style shortcuts.
  • See a live preview that updates after a short pause in typing, or only on Run if the author turns auto-run off.
  • See a console with what the code logs, errors with the line number in the author's code, and an input to run expressions in the preview.
  • Pick a preprocessor per panel: Sass or Less for CSS, TypeScript or Babel with JSX for JavaScript, Markdown or Pug for HTML.
  • Import npm packages by bare name and add external stylesheets or scripts by URL.
  • Save, get a short URL, keep a version history, fork any public pen, and embed a pen on another site.
  • Change layout: editors on top, left, or right; drag panel sizes; open the preview fullscreen or at a phone width.

Non-functional requirements

  • Isolation: no pen can read our cookies, tokens, or storage, act as the user on our site, or navigate the editor away. This is non-negotiable.
  • Feedback time: from the last keystroke to the new preview in under about 700 ms for a plain pen (500 ms debounce plus 200 ms to build and load), under 100 ms for a CSS-only edit after the debounce.
  • The editor never freezes: an infinite loop or a heavy computation in the preview must not stop you from typing the fix.
  • First load: an existing pen is readable within about 2 s on a mid-range phone on 4G. Compilers for languages the pen does not use are never downloaded.
  • No lost work: a crash, a closed tab, or a lost connection loses at most a couple of seconds of typing.
  • Accessible: every panel reachable by keyboard, the editor never traps focus, and the console is read out by screen readers.

Out of scope

We leave out the server side of running code (there is none: the browser runs everything), search and discovery pages, the asset host for uploaded images (the file-uploader guide covers that), and the editor internals such as syntax trees and incremental highlighting (planned as the code-editor-component problem). We also leave out full multi-file Node projects; we come back to how CodeSandbox and StackBlitz handle them in the packages chapter.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes a playground hard

Most apps run code their own team wrote and reviewed. A playground's whole job is to run code nobody reviewed, as fast as possible, again and again. Four properties follow from that, and every later chapter answers one of them.

  • Hostile by default. A pen can contain anything a script can do: read document.cookie, call fetch with the user's credentials, set top.location to a fake login page, pile up popups and dialogs, or mine coins. One careless boundary turns a link into account theft. This is cross-site scripting (XSS) offered as a feature, so the only safe answer is to make sure the script runs somewhere it cannot reach anything of ours.
  • Runs on every pause. A typist pauses every second or two. Each pause can mean: compile Sass and TypeScript, build an HTML document, throw away the old page, and start a new one. If one run costs 300 ms of main-thread work, typing stutters. So compile work moves off the main thread, and runs that a newer keystroke has replaced must be cancelled, not queued.
  • Can hang forever. while (true) {} is one line. Typing for (let i = 0; i < 10; i--) by accident is common. JavaScript cannot be interrupted from outside on the same thread, so if the preview shares a thread with the editor, the editor freezes and the user cannot even delete the bad line.
  • Lives in two worlds that cannot share memory. The preview is in another origin, so the editor cannot read its console, its errors, or its DOM directly. Everything crosses a postMessage channel, which copies data with the structured clone algorithm. DOM nodes, functions, and some objects cannot be copied, and messages from the preview are as untrusted as the code that sent them.

There is a fifth, quieter property: weight. A TypeScript compiler, a Sass compiler, and a bundler can each weigh several megabytes. A visitor who opens a plain HTML pen embedded in a blog post should download none of them. So the client is built from small pieces loaded only when a pen asks for them.

Here is the screen we are designing, with the names we use for its parts in the rest of the guide.

Wireframe of the pen editor at pen.example.com/ana/pen/xkqlw2pd. A header holds the title and the Run, Save, Fork, and Settings buttons. Below it, three editor panels side by side: HTML, CSS written in SCSS, and JS written in TypeScript, each a CodeMirror view. A drag handle separates them from the lower row, which holds the preview iframe from xkqlw2pd.penrun.io on the left and the console, a list with role log, on the right.pen.example.com/ana/pen/xkqlw2pdHeader: title, Run, Save, Fork, SettingsHTMLCodeMirror viewCSS: SCSSCodeMirror viewJS: TypeScriptCodeMirror viewdrag handlePreview iframexkqlw2pd.penrun.ioConsolerole=log
Three editors, one preview, one console. The preview is the only part that comes from another site; everything else belongs to the editor page.

Think first

Why can't the editor simply catch an infinite loop in the preview with a setTimeout that fires after 2 seconds and stops the page?

Running untrusted code: origins and the sandbox

Start with the naive version, because many first attempts look like it. The editor page at pen.example.com takes the three panels, joins them into one HTML string, and sets it as the srcdoc of an iframe. It works on the first try. It is also a disaster: an iframe with srcdoc and no sandbox attribute gets the origin of the page that made it.

So the pen's script runs as pen.example.com. It can read parent.document, call our API with the user's cookies, read the auth token from localStorage, and replace the editor with a fake login form. A pen that does this needs one line, and every visitor who opens it is hit.

Two browser tools stop this, and a playground needs both. The origin decides what a script may touch: an origin is the scheme, host, and port, and the same-origin policy keeps one origin's DOM, storage, and cookies away from another. The sandbox attribute on the iframe takes powers away from whatever runs inside, whatever its origin. The clickjacking-and-iframes lesson covers both in general; here we pick the exact setup.

Decision Where does the preview run?

Same origin, sandboxed without allow-same-origin

The editor builds srcdoc and sets sandbox="allow-scripts" without allow-same-origin.

The frame gets an opaque origin, a unique origin equal to no other, so it cannot read the parent.

Pros

  • No second domain, no server round trip.
  • Truly isolated from our cookies and DOM.

Cons

  • localStorage, sessionStorage, IndexedDB, and document.cookie throw SecurityError in an opaque origin, so many real demos break.
  • One flag added by mistake later (allow-same-origin) removes all protection.
  • Same site as the editor, so process isolation varies: desktop Chrome has moved such frames into their own process since version 127, but other browsers and Android may not, and then an infinite loop freezes the editor too.

A subdomain of our site

Previews load from run.pen.example.com, a different origin but the same site (same registrable domain).

Pros

  • Storage works, since it is a real origin.
  • Easy to set up: one DNS name, one certificate.

Cons

  • Same site: SameSite=Lax cookies still ride on requests from the pen to api.pen.example.com, so a pen can send a POST as the user.
  • A pen can set cookies for .pen.example.com (cookie tossing) and overwrite ours.
  • Browsers isolate processes by site, so it may share a process with the editor.

A separate site, one subdomain per pen

Previews load from xkqlw2pd.penrun.io, a different registrable domain we own only for running code.

Each pen gets its own subdomain, so pens are different origins from each other too.

Pros

  • Cross-site: our session cookies are never sent, cookie tossing cannot reach our domain, and storage works.
  • Desktop Chrome and Firefox usually put a cross-site frame in its own process, so a hung pen does not freeze the editor.
  • One pen cannot read another pen's localStorage.

Cons

  • A second domain, wildcard DNS, and a wildcard certificate to run.
  • Subdomains of penrun.io are still one site to each other unless the domain is on the Public Suffix List.

What we pick: Pick the separate site with a subdomain per pen, and still sandbox the iframe. The site boundary is what keeps our cookies and process safe; the sandbox removes powers no pen needs, such as navigating the editor tab away. CodePen made the same split: in 2019 it moved previews from the subdomain s.codepen.io to a separate domain, cdpn.io, while the editor stays on codepen.io.

One browser tab on pen.example.com. On the left, the editor app, and below it the cookies and storage of pen.example.com. On the right, an iframe from xkqlw2pd.penrun.io, a different site, holding the pen's untrusted code. postMessage passes both ways between them. Reading the editor's DOM and reading its cookies are blocked. A note lists the sandbox flags: allow-scripts, allow-same-origin, forms, modals, and popups, and no top navigation. Because the frame is another site, same-site cookies are never sent and it usually gets its own process.Browser tab: pen.example.comEditor appsession, SaveCookies, storagepen.example.comiframe: xkqlw2pd.penrun.ioPen codeuntrustedpostMessageread DOM: nocookies: noallow-scriptsallow-same-originforms, modals, popupsno top navigationanother site: no SameSite cookies sent, usually its own process
The frame's border is a site boundary, so postMessage is the only door. The sandbox flags then decide what the pen may do inside its own frame, and top navigation is never one of them.

Why a different site, not just a different origin

Origin and site are different boundaries, and a playground cares about both. run.pen.example.com and pen.example.com are different origins, so a script on one cannot read the other's DOM. But they are the same site, because the site is the scheme plus the registrable domain (example.com). Three things follow the site, not the origin.

First, SameSite cookies: a fetch("https://api.pen.example.com/pens/1/delete", { method: "POST", credentials: "include" }) from a same-site pen carries the user's session cookie even with SameSite=Strict. A plain POST like this needs no CORS preflight, so CORS only stops the pen reading the answer; the delete already happened.

Second, a page can set a cookie with Domain=pen.example.com from any subdomain, which lets a pen plant cookies our app later trusts. Third, browsers that isolate pages into processes do it per site, so same-site frames can share a process with the editor.

A separate registrable domain fixes all three. To also split pens from each other as sites, the owner of penrun.io can ask to add it to the Public Suffix List, the list browsers use to decide what counts as a registrable domain. GitHub did this for github.io, which is why two GitHub Pages sites cannot share cookies, and CodeSandbox did it for csb.app, its preview domain. A new entry can take weeks to months to reach shipped browsers and is hard to undo, so plan it early.

Which sandbox flags a playground needs

An empty sandbox attribute removes everything: scripts, forms, popups, dialogs, top navigation, and the real origin. Each allow- token gives one power back. The rule: give back exactly what a typical pen needs to behave like a normal page, and nothing that lets it act on the page around it.

FlagWhat it allowsPlayground needs it?
allow-scriptsRunning JavaScript.Yes. Without it, nothing runs.
allow-same-originKeeping the frame's real origin, so storage and cookies work.Yes, and safe only because the frame's origin is another site.
allow-modalsalert, confirm, prompt, and print.Yes. Beginners debug with alert.
allow-formsSubmitting forms.Yes. Form demos are common.
allow-popupswindow.open and links with target="_blank".Yes, for demos with external links. Popups still need a click, by browser rules.
allow-pointer-lockLocking the mouse pointer, for games.Yes, for game demos.
allow-downloadsStarting a file download.Optional. Export demos use it.
allow-top-navigationSetting top.location to any URL at any time.No. This lets a pen replace the editor with a phishing page.
allow-top-navigation-by-user-activationTop navigation only right after a user click.Usually no. Leave it off in the editor; maybe on in embeds.

Two more attributes finish the iframe. allow is the Permissions Policy for the frame: leave camera, microphone, and geolocation off by default and let a pen opt in through a setting, because a prompt saying "penrun.io wants your camera" is confusing inside our page. And title="Preview" gives screen reader users a name for the frame. Try the combinations below: pick an origin setup and flags, then see which attacks from a hostile pen succeed.

Preview origin
Explain
Sandbox lab. The frame's origin is pen.example.com, sandboxed with allow-scripts, allow-same-origin. read editor DOM and cookies: succeeds. POST to our API as the user: succeeds. plant a cookie for our domain: succeeds. navigate the editor tab away: blocked. open a popup: blocked. alert() a message: blocked. use localStorage: succeeds. freeze the editor with a loop: succeeds. 5 attacks succeed.frame origin: pen.example.comread editor DOM and cookiessucceedsPOST to our API as the usersucceedsplant a cookie for our domainsucceedsnavigate the editor tab awayblockedopen a popupblockedalert() a messageblockeduse localStoragesucceedsfreeze the editor with a loopsucceeds5 of 8 attacks succeed
read editor DOM and cookies: succeeds. Same origin and a real origin: parent.document and document.cookie are wide open. With a sandbox, the pen can even delete its own sandbox attribute and reload.

Check your understanding · Exercise 2

The preview loads from xkqlw2pd.penrun.io in an iframe with sandbox="allow-scripts allow-same-origin allow-popups allow-modals allow-forms". What can a hostile pen still do? Select all that apply.

Building the preview document

The preview needs one HTML document made from three panels, plus our own small runtime script for the console and loop protection. The question is how that document gets into a frame on xkqlw2pd.penrun.io. The editor cannot write into a cross-site frame directly. It has four ways to hand the document over, and they differ in origin, speed, and how well error line numbers come out.

Decision How does the built document reach the preview frame?

srcdoc from the editor

Set iframe.srcdoc = html.

The document's URL is about:srcdoc and it inherits the editor's origin, unless the sandbox makes it opaque.

Pros

  • Instant: no network at all.
  • Simple.

Cons

  • Either it runs as our origin (unsafe) or as an opaque origin where storage throws.
  • Inline scripts report errors by line in the whole HTML document, not in the author's JS panel.

blob: URL from the editor

URL.createObjectURL(new Blob([html], { type: "text/html" })) and point the iframe at it.

Pros

  • Instant, and scripts can be separate blob files with their own names.

Cons

  • A blob URL carries the origin of the page that created it: the editor. Same problem as srcdoc, slightly more hidden.

Server echo on the preview site

The editor submits a form with the three panels to https://xkqlw2pd.penrun.io/render, targeted at the iframe.

The server returns the assembled HTML.

Pros

  • A real origin on the right site. Works in every browser.
  • The classic way, used by playgrounds for over a decade.

Cons

  • A network round trip on every run, often 50 to 300 ms.
  • Our servers take a request every time anyone pauses typing.

Runner page plus postMessage

The iframe loads a tiny static runner page from xkqlw2pd.penrun.io/runner, cached for a long time.

The runner says ready; the editor posts the built files; the runner turns them into blob URLs of its own origin and navigates itself to the HTML one.

Pros

  • A real origin on the right site, so storage works.
  • No server work per run once the runner is cached.
  • The JS is its own blob file, so errors point at the author's file and line.

Cons

  • A handshake to get right, with origin checks on both sides.
  • Needs a cached runner, so the very first run pays one request.

What we pick: Use the runner page. It gives the preview a real, separate origin without a server call per run. Keep the server echo as a fallback for old embeds and for opening a pen's preview as a top-level page with a shareable URL.

Building the preview document. The HTML, SCSS, and TypeScript panels go to the build worker, which compiles them, adds loop guards, and makes a source map. The files cross to the runner page on xkqlw2pd.penrun.io over postMessage. The runner builds a document in this order: runtime.js first, then the import map, then external stylesheets, then the style element with id pen-css, then the body with the user's HTML, and last a module script loading pen.js from a blob URL. Then it calls location.replace with the blob URL of that document.HTMLSCSSTypeScriptBuild workercompile, guard, mapvia postMessagerunner: xkqlw2pd.penrun.ioruntime.jsimport mapexternal stylesheetsstyle#pen-cssbody: user HTMLmodule: blob pen.jslocation.replace(doc)runtime first, then import map,CSS in head, JS module last
Order matters: our runtime runs before any user code, and the import map must come before the first module. The JS is its own blob file on the preview's origin, so errors keep their file name and line.

What goes into the document, in order

Order matters because scripts run top to bottom. Our runtime must be first, so it wraps console and installs error handlers before any user code runs. The import map must come before the first module script, or the browser ignores it.

User CSS goes in the head so the first paint is already styled. The author's JS goes last, as a module that loads from its own blob URL. Every URL in the document must be absolute, because a relative path cannot be resolved against a blob: URL.

ts
// Runs inside the runner page, on xkqlw2pd.penrun.io.
function buildDocument(run: RunFiles): string {
  const jsUrl = URL.createObjectURL(
    new Blob([run.js + "\n//# sourceMappingURL=" + run.mapUrl], { type: "text/javascript" }),
  );
  return `<!doctype html>
<html><head>
<meta charset="utf-8">
<script src="${location.origin}/runtime.js"></script>
<script type="importmap">${JSON.stringify(run.importMap)}</script>
${run.externalCss.map((u) => `<link rel="stylesheet" href="${u}">`).join("")}
<style id="pen-css">${run.css}</style>
</head><body>
${run.html}
<script type="module" src="${jsUrl}"></script>
</body></html>`;
}
// Then: location.replace(URL.createObjectURL(new Blob([doc], { type: "text/html" })));

Because the JS file is a blob of the runner's own origin, errors inside it are not muted. A script from another origin without CORS reports every error as just "Script error." with no line, which would make the console useless. The sourceMappingURL comment points at a map the worker produced, so DevTools shows the author's TypeScript, and our console can translate lines too (next chapter).

Reload the frame or swap in place?

Rerunning JavaScript inside the same page is a trap. The old code's timers, event listeners, requestAnimationFrame loops, and global variables are still alive, so the new run fights the ghost of the old one.

The only clean reset is a new document. So for any HTML or JS change, the editor removes the old iframe and creates a new one, which the browser tears down completely. A fresh iframe on a cached runner usually costs tens of milliseconds before the pen's own code starts.

CSS is different: a stylesheet has no state. When only the CSS panel changed, the editor posts just the compiled CSS and the runtime sets document.getElementById("pen-css").textContent. The page restyles in one frame and keeps everything: the scroll position, a half-filled form, an open menu, a game in progress. This one fast path is what makes styling in a playground feel live.

Check your understanding · Exercise 3

Which statements about delivering the preview document are true? Select all that apply.

From keystroke to preview: compiling and live reload

Now the timing. The naive version runs the pen on every input event. Type document.querySelector at 6 characters per second and you get 22 runs, each building a document and starting a page, most of them on half-typed code that throws a syntax error. The preview flickers, the console fills with nonsense, and on a slow laptop the typing itself starts to lag.

The fix has three parts. Wait for a pause (debounce: the debounce-throttle lesson covers the mechanics). Do the heavy work off the main thread. And make every run cancellable, so the newest keystroke always wins.

  • Debounce at 500 ms. Shorter, and the preview restarts in the middle of a word; longer, and it feels laggy. Authors of heavy pens (a 3D scene, a big canvas) can switch auto-run off and use the Run button or Ctrl+Enter (Cmd+Enter on a Mac).
  • Number every run. A counter runId goes up on every run. Results from the worker and messages from the preview carry it, and anything with an old runId is dropped. This is how a slow Sass compile from three keystrokes ago never overwrites the newer result.
  • Skip unchanged panels. Keep a hash of each panel's source and its last compiled output. Changing HTML does not recompile Sass.
  • Keep the last good preview. If compiling fails, show the error in the editor gutter and the console, and leave the old preview running. Replacing a working page with a blank one on every typo is the most annoying thing a playground can do.

Decision Where do Sass, TypeScript, and JSX get compiled?

On the main thread

Import the compiler in the editor bundle and call it in the debounce handler.

Pros

  • Simplest code.

Cons

  • A 200 ms Sass compile is 200 ms where keystrokes are not drawn.
  • Cannot be stopped if a compile hangs.

On our server

POST the source to a compile endpoint; get back JS, CSS, and a source map.

Pros

  • No compiler download for the visitor.
  • Any compiler version, any language.

Cons

  • A round trip per run, and a server bill that grows with every pause in typing.
  • Fails offline.

In a Web Worker

A dedicated worker loads compilers on demand with dynamic import() and answers { runId, files } messages.

Pros

  • The main thread stays free for typing.
  • A stuck compile is stopped with worker.terminate() and a fresh worker.
  • Works offline once the compiler is cached.

Cons

  • Compilers are large: the esbuild WebAssembly binary is about 14 MB raw and about 3.7 MB gzipped; the Dart Sass npm package unpacks to about 6 MB.
  • One more message boundary to debug.

What we pick: Compile in a worker, and pick small compilers. For TypeScript and JSX we only need to strip types and turn JSX into calls, not type-check, so a fast transform like Sucrase (about 1 MB unpacked) or esbuild's transform is enough. Load each compiler the first time a pen asks for it, and keep it in the HTTP cache with a versioned URL.

A run as a sequence diagram between the editor, the build worker, and a new preview frame. After typing pauses for 500 ms, the editor asks the worker to build run 8 from HTML, SCSS, and TypeScript. The worker replies with JS, CSS, and a source map for run 8. The editor removes the old frame and adds a new one that loads the runner. The frame says ready. The editor sends the run 8 files. The frame sends console entries and heartbeats tagged run 8. Later, run 9 is sent to the worker, which replies with a syntax error at line 12, so the old preview stays.EditorBuild workerNew framerun 8: html, scss, tsjs, css, map (run 8)remove old frame, add newreadyrun 8: filesconsole, heartbeat (run 8)run 9: next pausesyntax error, line 12
The failure path stops at the worker: a syntax error marks the gutter and the console, and run 8's preview keeps running. Every message carries its runId, so anything late from an old frame is dropped.

Source maps: errors on the author's line

Compiling moves lines around. TypeScript drops type-only lines, JSX expands, and our loop protection inserts code. An error at line 41 of the compiled file might be line 33 of what the author typed.

A source map is a JSON file that maps every position in the output back to a position in the source. The worker produces one for each compile step and chains them, so the final map goes from the file that runs straight back to the panel.

ts
// In the build worker.
self.onmessage = async (e: MessageEvent<BuildRequest>) => {
  const { runId, js, jsLang } = e.data;
  try {
    const step1 = jsLang === "typescript"
      ? (await loadSucrase()).transform(js, { transforms: ["typescript", "jsx"], sourceMapOptions: { compiledFilename: "pen.js" }, filePath: "pen.tsx" })
      : { code: js, sourceMap: null };
    const step2 = protectLoops(step1.code);          // returns { code, map }
    const map = chainMaps(step2.map, step1.sourceMap); // output to panel
    self.postMessage({ runId, ok: true, js: step2.code, map });
  } catch (err) {
    const { line, column, message } = toPosition(err);
    self.postMessage({ runId, ok: false, error: { panel: "js", line, column, message } });
  }
};

When the preview reports a runtime error, it sends the file name, line, and column from the ErrorEvent. The editor asks the worker to translate them through the map, then shows the author's line in the console, underlines it in the JS panel, and makes the console entry a link that jumps to it. DevTools does the same translation on its own thanks to the sourceMappingURL comment.

The run as a state machine

A run moves through a few states, and writing them down prevents most timing bugs: a debounce that fires during a compile, a ready message from an iframe that was already replaced, a loop warning from an old run.

The run state machine. Idle goes to waiting on an edit. Waiting, a 500 ms debounce, goes to compiling when typing pauses. Compiling, in the worker, goes to loading when it succeeds, or to compile error, which keeps the old preview. Loading a new frame goes to running when the frame says ready. Running goes to stopped when no heartbeat arrives for 3 seconds. Any edit from any state goes back to waiting with a new run id. A CSS-only edit while running swaps the styles and stays running.idlenothing yetwaiting500 mscompilingin workerloadingnew frameeditpauseokcompile errorold preview kepterrorrunningheartbeatsreadystoppedframe removedno beat 3 sany edit, from any state:back to waiting, runId + 1CSS-only edit while running:swap styles, stay running
Two failure states, and neither blanks the screen: a compile error keeps the old preview, and a hung page is removed with a clear notice. Any edit restarts the cycle with a new run id.

Check your understanding · Exercise 4

A user types fast in the Sass panel. Compiles take 300 ms and the debounce is 500 ms. Which behaviors does this design guarantee? Select all that apply.

Stopping infinite loops

Type for (let i = 0; i < 10; i++) and, halfway through, the code reads for (let i = 0; i < 10; i). With auto-run on, the debounce fires on the pause and the preview starts a loop that never ends.

JavaScript has no way to interrupt a running loop from the outside on the same thread: no timer fires, no event is handled, and no message is read until the loop returns. If the editor shares that thread, the user cannot even type the missing ++. A playground has to plan for this, because it happens to every user sooner or later.

Decision How do we stop a pen that never yields?

Inject loop guards

Before running, parse the JS and add a check inside every for, while, and do loop.

The check reads the clock and breaks out after a time limit.

Pros

  • Works in every browser, whatever the process model.
  • The page survives: the loop breaks and the rest of the code keeps running.

Cons

  • Only catches loops in code we transformed: not long regexes, not huge JSON.parse calls, not loops inside libraries loaded from a CDN.
  • Breaking a loop leaves the program in a state the author never planned.
  • A slight cost on every loop iteration.

Run user JS in a worker and terminate it

Run the code in a Web Worker; if it stops answering, call terminate().

Pros

  • A real, guaranteed kill.

Cons

  • Workers have no DOM. Almost every pen touches the DOM, so this only fits pure computation, like an online judge.

Process isolation plus a watchdog

The cross-site preview runs in its own renderer process where the browser supports it.

The runtime posts a heartbeat every 250 ms; if none arrives for 3 s, the editor removes the iframe and shows a notice.

Pros

  • Catches every kind of hang, including ones guards miss.
  • The editor stays responsive the whole time.

Cons

  • Depends on the browser: process-per-site is standard on desktop Chrome and Firefox, but not every browser or low-memory phone gives each cross-site frame its own process.
  • Kills the whole page, not just the loop.

What we pick: Use loop guards and the watchdog together. Guards fix the common case gently, everywhere. The watchdog catches what guards miss on browsers that isolate the frame. On top, a safe mode: if the last run never sent its first heartbeat before the tab died, reopen the pen with auto-run off.

How the guards work

CodePen has described its own version publicly. It rewrites the author's JS before running it, inserting a call to window.CP.shouldStopExecution(id) inside each loop, which breaks the loop when it returns true, and a call to window.CP.exitedLoop(id) right after the loop.

In a 2016 post, CodePen gave its timing: start monitoring 2 seconds after the page starts, stop a loop that has run 2.2 seconds without exiting, and stop monitoring after 5 seconds. A pen can raise the limit with window.CP.PenTimer.MAX_TIME_IN_LOOP_WO_EXIT = 6000. Note what the numbers imply: once monitoring stops, a loop started later by a click handler is not caught by the guard, which is one more reason to keep a watchdog.

Loop protection, before and after. On the left, what the author wrote: let i equals 0, while i is less than 10, draw i, with no increment, so it never ends. On the right, what runs: the same loop with a first line inside, if __pen.guard(1) then break, and after the loop a call to __pen.exited(1). After 1 second inside loop 1, the guard breaks it and warns in the console. A source map keeps the author's line numbers.what you wrotelet i = 0;while (i < 10) {draw(i);}what runslet i = 0;while (i < 10) {if (__pen.guard(1)) break;draw(i);}__pen.exited(1);after 1 s inside loop 1:break, warn in the consolea source map keepsthe author's line numbers
The worker adds two lines and nothing else. guard(1) reads the clock and breaks the loop after the limit; exited(1) resets it when the loop ends normally.

Our version does the same with our own names. The worker parses the JS with a small parser such as Acorn, finds every loop, and inserts the guard with a string-editing library such as MagicString, which also emits a source map so line numbers stay correct. The guard itself lives in the runtime script that loads first.

ts
// In runtime.js, loaded before any pen code.
const LIMIT_MS = 1000;
const started = new Map<number, number>();
(window as any).__pen = {
  guard(id: number): boolean {
    const now = performance.now();
    const first = started.get(id) ?? (started.set(id, now), now);
    if (now - first < LIMIT_MS) return false;
    started.delete(id);
    parent.postMessage({ type: "loop-stopped", runId: RUN_ID, loopId: id }, EDITOR_ORIGIN);
    return true; // the inserted code does `break`
  },
  exited(id: number) { started.delete(id); },
};

We stop loops after 1 second rather than 2.2, because our debounce already filters most half-typed code, and a second of frozen preview is long enough to notice. Calling performance.now() on every iteration adds a small cost to each pass; for hot loops the guard can check the clock only every 1,000 iterations with a counter. When a guard trips, the console shows a clear message with the line of the loop, mapped through the source map, and a link to raise the limit for this pen.

The watchdog and safe mode

The runtime sends { type: "heartbeat", runId } every 250 ms with setInterval. A busy page cannot send it, so silence means the main thread of the preview is stuck. After 3 seconds of silence the editor removes the iframe, which lets the browser tear the page down, and shows "This pen stopped responding. Run again or edit first."

Before each run, the editor also writes { penId, runId, startedAt } to sessionStorage, and clears it on the first heartbeat. If the whole tab froze and the user reloaded it, the key is still there, so the pen opens with auto-run off instead of freezing the tab again.

One more rule keeps the watchdog honest: pause it while the editor tab is hidden. Browsers throttle timers in background tabs, to about once a second and later even less often, so a slow heartbeat there is not a hang.

Check your understanding · Exercise 5

Which hangs do injected loop guards NOT catch? Select all that apply.

Console capture across the frame boundary

Beginners live in the console. They want console.log(user) to show the object, and they want the red error with a line number they can click. But the preview is on another site: the editor cannot read its console, and the browser's DevTools console is hidden on phones and confusing for beginners. So the runtime script inside the preview copies everything worth showing and sends it to the editor over postMessage.

Wrapping console methods

The runtime replaces console.log, info, warn, error, debug, table, and clear with wrappers that serialize the arguments, queue them, and then call the original method, so DevTools still works for people who use it. It also listens for error events (uncaught exceptions, and in the capture phase, failed script and img loads) and unhandledrejection events (a promise rejected with no catch).

ts
// runtime.js, inside the preview.
const queue: LogEntry[] = [];
for (const level of ["log", "info", "warn", "error", "debug"] as const) {
  const original = console[level].bind(console);
  console[level] = (...args: unknown[]) => {
    queue.push({ level, args: args.map((a) => serialize(a)), at: performance.now() });
    original(...args);
  };
}
addEventListener("error", (e) => {
  if (e instanceof ErrorEvent) {
    queue.push({ level: "error", args: [serialize(e.error ?? e.message)], file: e.filename, line: e.lineno, col: e.colno, at: performance.now() });
  } else if (e.target instanceof Element) {
    queue.push({ level: "error", args: [`Failed to load ${e.target.tagName.toLowerCase()}`], at: performance.now() });
  }
}, true);
addEventListener("unhandledrejection", (e) => {
  queue.push({ level: "error", args: ["Uncaught (in promise)", serialize(e.reason)], at: performance.now() });
});
setInterval(() => {
  if (queue.length) parent.postMessage({ type: "console", runId: RUN_ID, entries: queue.splice(0, 200) }, EDITOR_ORIGIN);
}, 50);

Messages go out in batches every 50 ms with at most 200 entries each. A pen that logs inside a requestAnimationFrame loop produces 60 entries a second on a 60 Hz screen, and one message per entry would flood the editor's main thread.

Batching also means a pen that logs 1 million lines in a loop sends them in chunks the editor can drop politely: the console keeps the last 1,000 entries and shows "12,480 older messages hidden". The runtime caps its own queue the same way and sends a count of what it dropped, so the frame does not hoard memory either.

Serializing what structured clone cannot copy

postMessage copies data with the structured clone algorithm. It handles plain objects, arrays, Map, Set, Date, typed arrays, and even cycles. But it throws a DataCloneError on functions, DOM nodes, and symbols, and it drops class prototypes and turns getters into plain values.

Logging document.body or an object with a method would crash the runtime if we posted the raw value. So the runtime turns every argument into a safe preview first.

Value loggedWhat structured clone doesWhat we send
{ a: 1, nested: { b: [1, 2] } }Copies it.A tree of up to 3 levels and 100 keys per level, with "..." beyond that.
An object that contains itselfCopies the cycle.The same tree, with the repeated reference marked as [Circular], so the console view never recurses forever.
document.querySelector("#app")Throws DataCloneError.A summary: tag, id, classes, child count, like div#app.card (3 children), and a flag the console uses to highlight it in the preview on hover.
function greet() {}Throws DataCloneError.f greet() as text.
new Error("boom")Copied in current browsers.name, message, and stack, with the stack's lines mapped back to the author's source.
new User("ana")Copies the fields, loses the class.The fields plus the class name from constructor.name, shown as User { name: "ana" }.

Checking every message

Here is the subtle part. Our runtime and the pen's code run in the same origin, the same page, the same global scope. So the pen can call parent.postMessage too, and forge any message our runtime would send.

Checking event.origin proves the message came from the preview frame; it cannot prove it came from our runtime rather than the pen. So the editor treats every field as untrusted data from a stranger: it checks the sender, checks the run, validates the shape, caps sizes, and renders strings as text, never as HTML.

ts
// In the editor.
addEventListener("message", (event) => {
  if (event.source !== frame.contentWindow) return;          // only our current frame
  if (event.origin !== `https://${penId}.penrun.io`) return;  // and its expected origin
  const msg = event.data;
  if (typeof msg !== "object" || msg === null) return;
  if (msg.runId !== currentRunId) return;                     // late message from an old run
  if (msg.type === "console" && Array.isArray(msg.entries)) {
    consoleStore.add(msg.entries.slice(0, 200).map(sanitizeEntry));
  } else if (msg.type === "heartbeat") {
    watchdog.beat();
  }
});

The other direction needs the same care. The runner page accepts files and console commands only when event.origin is exactly our editor's origin and event.source is parent, because anyone can frame xkqlw2pd.penrun.io and post to it. When the editor posts, it names the target origin (frame.contentWindow.postMessage(files, "https://xkqlw2pd.penrun.io")) instead of "*", so nothing is delivered if the frame has navigated somewhere else.

Console capture as a sequence diagram between the pen's code, our runtime in the frame, the editor, and the console panel. The pen calls console.log with an element and a user object. The runtime serializes them and posts a batch tagged run 8 to the editor. The editor checks the source, origin, and run id, and the console renders the entries as text. Then a late batch tagged run 7 arrives from the old frame, and the editor drops it. Notes: the element becomes a summary, a div with id app and 3 children, and the object becomes User with name ana; messages with the wrong source, the wrong origin, or an old run id are dropped.Pen codeRuntimeEditorConsolelog(el, user)batch, run 8render as textlate batch, run 7el: div, id app, 3 childrenuser: User { name: ana }dropped: wrong source,wrong origin, or old runId
The editor never trusts a message just because it arrived. It checks event.source, event.origin, and runId, caps the batch, and renders text, because the pen can call parent.postMessage too.

The lab below runs the whole pipeline from the last three chapters: type, wait for the debounce, compile in the worker, reload the frame, and watch messages flow back. Try a late or forged message, a compile error, and an infinite loop.

Scenario
Step 1 of 6
Run pipeline, JS edit, step 1 of 6. Stages: editor, debounce, build worker, preview frame, message check, console. Active now: editor. Console so far: empty.EditorkeystrokeDebounce500 ms, runIdBuild workercompile, guardPreview framerunner, runtimeMessage checksource, origin, runConsoletext onlyfilespostMessage both waysconsole: emptyno failure at this step
You type console.log("hi") in the JS panel. The store updates and a draft goes to IndexedDB.

Check your understanding · Exercise 6

A pen calls console.log(document.body, window). What should happen? Select all that apply.

Importing npm packages

An author types import confetti from "canvas-confetti". In Node that works because a resolver looks in node_modules. In a browser it throws: a bare specifier (a name, not a URL) means nothing to the browser on its own.

There is also no node_modules, since we are not going to run npm install for every visitor who opens a pen. Something has to turn the name into a URL that serves browser-ready JavaScript.

Decision How do bare imports work in the preview?

Import map plus an ESM CDN

When the author adds a package, the editor resolves its exact version and writes an import map entry like "canvas-confetti": "https://esm.sh/canvas-confetti@1.9.3".

The browser loads modules straight from the CDN, which converts npm packages to ES modules on its side.

Pros

  • No bundler download: zero extra kilobytes in the editor.
  • Import maps work in all current major browsers (Safari since 16.4 in 2023).
  • Package files are shared through the CDN cache across pens.

Cons

  • Many small requests for packages with deep dependency trees.
  • Two packages can pull two copies of the same dependency (two Reacts break hooks) unless versions are pinned together.
  • We depend on a third-party CDN being up.

Bundle in the browser

Fetch package files from a CDN into memory and bundle them in the worker, for example with esbuild-wasm. CodeSandbox's Sandpack works this way for client-side projects, with its own in-browser bundler.

Pros

  • Real bundler behavior: one copy of each dependency, CSS imports, JSON imports, multi-file projects.
  • Converts CommonJS packages itself, without relying on the CDN.

Cons

  • Several megabytes of WebAssembly before the first run.
  • Slower first run while package files download and bundle.
  • Much more code of our own to own.

Run Node in the browser

StackBlitz's WebContainers run a Node-compatible runtime in WebAssembly in the tab, with a real npm install and dev server.

Pros

  • Full projects: Vite, Next.js, a real terminal.

Cons

  • Needs cross-origin isolation (the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers) for SharedArrayBuffer, which limits what the page can embed.
  • Fully supported only in desktop Chromium browsers; StackBlitz lists Firefox and Safari 16.4 or later as beta.
  • Heavy, and a commercial product with its own license terms.
  • Far more than a pen with three panels needs.

What we pick: For a CodePen-style pen, use an import map and an ESM CDN, with versions pinned when a package is added. Offer in-browser bundling only for a separate multi-file project mode, and mention WebContainers as the answer when the interviewer asks about running full Node projects.

Two ways to resolve npm imports, side by side. Left, import map plus an ESM CDN: the pen imports canvas-confetti by its bare name, the import map turns the name into a pinned URL, and the browser loads it from esm.sh. Right, bundling in the browser: package files are fetched into memory, esbuild-wasm, about 3.7 MB gzipped, bundles them, and the result is one bundle with one copy of each dependency.import map + ESM CDNimport confettibare name in pen.jsimport mapname to pinned URLesm.shcanvas-confetti@1.9.3bundle in the browserpackage filesfetched into memoryesbuild-wasmabout 3.7 MB gzippedone bundleone copy of each dependency
The import map costs the editor nothing and lets the browser fetch modules directly. The in-browser bundler costs megabytes up front but behaves like a real build, which full projects need.

Pinning versions and avoiding duplicates

A pen saved in 2026 with react resolved to latest would break the day React ships a breaking major version. So the editor resolves the version once, when the author adds the package (from the npm registry or the CDN), and stores the exact version in the pen. Opening the pen five years later loads the same files. The ESM CDNs build each version once and cache it, so pinned URLs are fast and stable.

Duplicates are the other trap. If react-dom from the CDN imports its own copy of react, and the pen imports another, hooks fail with an "invalid hook call" error. The fix is to make sure both resolve to one URL: map both names in the import map, and ask the CDN to resolve shared dependencies to the pinned versions (esm.sh, for example, takes a deps query parameter for this).

json
{
  "imports": {
    "react": "https://esm.sh/react@18.3.1",
    "react-dom/client": "https://esm.sh/react-dom@18.3.1/client?deps=react@18.3.1",
    "canvas-confetti": "https://esm.sh/canvas-confetti@1.9.3"
  }
}

Packages are third-party code, so the third-party-scripts lesson applies, but in a gentler form than usual: they load into the preview, not the editor. A compromised CDN file can attack the pen's own page and nothing else, because the preview cannot touch our origin. That is one more payoff of putting the boundary in the right place. The editor's own Content Security Policy (CSP) still lists only our domains in script-src, so no package code ever runs on pen.example.com.

Check your understanding · Exercise 7

Which statements about package imports in this design are true? Select all that apply.

The editor and the layout

Nobody should build a code editor for a playground from a textarea. Syntax highlighting, autocomplete, bracket matching, multiple cursors, search, undo, and input methods for every language are years of work. The decision is which editor to embed, and it comes down to weight and phones. How an editor works inside (its document model, incremental parsing, and rendering) is its own problem, the code-editor-component problem; here we treat it as a part with a clear interface.

Decision CodeMirror 6 or Monaco?

CodeMirror 6

A modular editor built on contenteditable, assembled from small packages: the view, the state, one package per language, and optional features.

Pros

  • Small: a basic setup with one language is a few hundred KB minified, much less gzipped, and every feature is opt-in.
  • Works on phones and tablets: native selection, touch, and input methods.
  • Accessible by design: no keyboard trap by default, and screen readers read the content.

Cons

  • No built-in TypeScript type-aware autocomplete; you add it yourself if you want it.
  • You assemble features yourself.

Monaco

The editor from VS Code, packaged for the web, with language services running in web workers.

Pros

  • VS Code features out of the box: rich TypeScript IntelliSense, go to definition, the command palette, a diff view.
  • Familiar to millions of developers.

Cons

  • Heavy: megabytes of JavaScript and worker code even for one language; the npm package unpacks to about 100 MB.
  • Its own docs have long said mobile browsers are not supported, and touch editing is weak.
  • Grabs Tab for indentation by default.

What we pick: CodeMirror 6. A large share of pen views are embeds and phone visits where weight and touch matter more than IntelliSense. Three CodeMirror views share one set of core packages, and each panel lazily loads only its language package. A project mode for bigger TypeScript work could load Monaco on demand later.

Resizable panes

The layout is a split view: editors in a row or column, the preview beside or below them, and the console under the preview. Each divider is a drag handle. Three details make dragging feel solid. First, call setPointerCapture on pointerdown, so the handle keeps receiving pointermove even when the pointer races ahead of it.

Second, while dragging, set pointer-events: none on the preview iframe. Pointer events over an iframe normally go to the frame's own document, and capture across frames has not always been reliable in every browser; without this line you get the classic "the divider gets stuck over the preview" bug. Third, apply the size once per animation frame, not on every event.

ts
handle.addEventListener("pointerdown", (e) => {
  handle.setPointerCapture(e.pointerId);
  frame.classList.add("is-dragging"); // CSS: pointer-events: none
  let pending = 0;
  const onMove = (m: PointerEvent) => {
    cancelAnimationFrame(pending);
    pending = requestAnimationFrame(() => {
      const rect = container.getBoundingClientRect();
      const ratio = clamp((m.clientX - rect.left) / rect.width, 0.15, 0.85);
      container.style.setProperty("--split", String(ratio));
    });
  };
  handle.addEventListener("pointermove", onMove);
  handle.addEventListener("pointerup", () => {
    handle.removeEventListener("pointermove", onMove);
    frame.classList.remove("is-dragging");
    saveLayout(); // fractions, to localStorage
  }, { once: true });
});

Store pane sizes as fractions (0.4 of the width), not pixels, so a layout saved on a 27-inch monitor still works on a laptop. Keyboard users need the same control: give each handle role="separator", aria-orientation, aria-valuenow as a percentage, and tabindex="0", and move it 5% per arrow key press.

Fullscreen and device sizes

Fullscreen is a route, not just the Fullscreen API: /:user/full/:id renders only the preview frame, so the URL can be shared and opened on a phone to test.

Device preview sets the iframe's width to a phone or tablet size, like 375 or 768 CSS pixels, and scales it down with a CSS transform when the pane is narrower. The pen's media queries respond to the iframe's own width, so this is a real test of the responsive layout, without a reload. It is not a real phone, though: touch events, the user agent string, and device pixel ratio do not change.

Three layouts as three small windows. Top: the html, css, and js editors in a row above a wide preview, the default. Left: the three editors stacked in a column beside a tall preview, for wide screens. Device: the preview frame set to 375 CSS pixels wide and scaled to fit, so the pen's media queries fire.tophtmlcssjspreviewlefthtmlcssjspreviewdevice375scaled to fitdefaultwide screensmedia queries fire
Pane sizes are saved as fractions, so a layout made on a big monitor still fits a laptop. The device view changes the frame's width, not the device: touch and pixel ratio stay the same.

Check your understanding · Exercise 8

Which choices fit a playground where most views are embeds and phone visits? Select all that apply.

Architecture: the parts and their boundaries

With the hard decisions made, the architecture follows from them. Two sites carry the whole design.

The editor on pen.example.com holds everything worth stealing: the session cookie, the account, the save button. The preview on a per-pen subdomain of penrun.io holds nothing but the pen. Every arrow between them is a postMessage that both sides check.

PartLives inResponsibility
Editor panelsEditor page, main threadThree CodeMirror views; report changes; show error marks in the gutter.
Pen storeEditor page, main threadThe current pen: sources, languages, packages, settings, dirty flag, and revision.
Run controllerEditor page, main threadDebounce, runId, the run state machine, creating and removing preview frames, the watchdog.
Build workerDedicated Web WorkerLoad compilers on demand, compile, insert loop guards, chain source maps, map error positions.
Runner and runtimePreview frame on the run siteAccept files from the editor only; build the document; wrap console; send heartbeats; break long loops.
ConsoleEditor page, main threadValidate messages, keep the last 1,000 entries, render them as text, evaluate typed expressions in the preview.
Draft storeIndexedDB in the editor originAutosave every change within about a second; recover after a crash.
Pen API clientEditor pageLoad, save, fork, and list revisions over HTTPS.
ESM CDNThird partyServe npm packages as browser modules at pinned versions, straight to the preview.

Why the boundaries sit where they do: the build worker is separate because compiling is the only heavy, blockable work the editor does, and a worker can be terminated. The runner is separate because it runs strangers' code. The draft store is separate from the API client because saving locally must work when the network does not. And the run controller owns the frame's lifecycle because only one place should decide when an old frame dies, or messages from two runs mix.

The playground's component architecture. Inside the editor site, pen.example.com: the editor panels send edits to the pen store. The store writes drafts to the draft store in IndexedDB, saves through the pen API client, and sends changes to the run controller. The run controller sends sources to the build worker and sends each run to the runner and runtime inside a sandboxed frame on xkqlw2pd.penrun.io. The runtime sends logs back to the console. The frame loads modules from the ESM CDN.Editor: pen.example.comEditor panelsCodeMirror 6Pen storesources, revisioneditsDraft storeIndexedDBdraftRun controllerdebounce, runIdchangePen API clientload, save, forksaveBuild workercompile, guard, mapsourcesConsolelast 1,000 entriesxkqlw2pd.penrun.ioRunner, runtimesandboxed framerunlogsESM CDNpackagesmodules
Each boundary has a reason: the worker can be killed, the frame runs strangers' code, the draft store works offline, and only the run controller decides when a frame lives or dies.

The data flow for the main screen follows the same lines. Opening a pen fetches its JSON from the API (cached on the CDN for public pens). Each edit goes from the panel to the store, then two ways at once: to IndexedDB as a draft, and to the run controller.

The controller sends sources to the worker and compiled files to the preview. Console entries, errors, and heartbeats come back. Saving sends the store's snapshot to the API, which answers with a new revision number.

Data flow for the pen screen. Reading: the pen API and database send pen JSON through the CDN, cached 60 seconds for public pens, to the API client, which fills the pen store. Editing: the store writes a draft to IndexedDB about a second after typing stops, and sends sources to the run controller. The run controller sends sources to the build worker and gets back JS, CSS, and a source map, then sends a run message to the preview frame. The preview frame sends logs and errors to the console. Writing: saving sends the pen with its base revision from the API client back to the pen API.Pen API+ databaseCDN60 s, publicAPI clientfetchPen storememoryJSONJSONpenPUT pen, baseRevisionIndexedDBdraft, 1 sRun controllerrunIdsourcesBuild workercompilejs, css, mapPreview framepenrun.iorunConsoletext onlylogs
Reads flow left to right through the CDN; saves skip the CDN and go straight to the API with baseRevision. Nothing in the run loop touches the network except package downloads.

Check your understanding · Exercise 9

Which of these would break a boundary in this architecture? Select all that apply.

Data model and interfaces

A pen is small, usually under 50 KB per panel with a hard cap of 1 MB, so each save stores the whole pen as one immutable revision: no diffs, no chunks. What matters more is where each piece of state lives, because the wrong home causes the classic bugs: a draft kept only in memory, or console entries from an old run mixed into a new one.

ts
type Lang = "html" | "markdown" | "pug" | "css" | "scss" | "less" | "js" | "ts" | "jsx";
type Pen = {
  id: string;              // 8 lowercase base36 chars, also the preview subdomain (hostnames ignore case)
  ownerId: string;
  title: string;
  revision: number;        // goes up on every save
  forkedFrom?: { penId: string; revision: number };
  panels: { html: Panel; css: Panel; js: Panel };
  packages: Record<string, string>;   // "canvas-confetti" to "1.9.3"
  externalCss: string[];
  settings: { autoRun: boolean; loopLimitMs: number; layout: "top" | "left" | "right" };
  visibility: "public" | "unlisted" | "private";
  updatedAt: string;
};
type Panel = { lang: Lang; source: string };
type Draft = { penId: string; baseRevision: number; panels: Pen["panels"]; savedAt: number };
type LogEntry = { runId: number; level: "log" | "info" | "warn" | "error" | "debug"; args: Preview[]; line?: number; at: number };
type Preview = { kind: "text" | "number" | "object" | "array" | "node" | "error" | "function"; label: string; children?: Preview[] };
StateWhere it livesWhy there
Saved pen and revisionsServer; public pens cached on the CDN for 60 sThe source of truth, shared by link.
Pen being editedIn-memory store in the editorChanges on every keystroke; panels and the run controller read it.
DraftIndexedDB, keyed by pen idSurvives a crash or closed tab; holds baseRevision to detect a newer save elsewhere.
Compiled output and mapsBuild worker memory, keyed by source hashSkip recompiling unchanged panels.
Run state and consoleEditor memory, keyed by runIdDies with the run; never saved.
Layout and pane sizeslocalStorage in the editor originA per-device preference.
Pen id, view (editor, full, embed)URLShareable and bookmarkable.
The data model in two places. On the server: a Pen with id, owner, revision, and visibility has many immutable Revisions, each holding panels and packages, and a fork records forkedFrom as a pen and revision. In the browser: a Draft in IndexedDB holds the panels and the baseRevision it started from, which points at a server revision. A Run in memory holds the runId and its state, and has many LogEntries, of which the console keeps the last 1,000.ServerPenid, owner, revisionhas manyRevisionpanels, packages: immutablefork: forkedFrom= pen id + revisionBrowserDraftIndexedDB: baseRevisionbased onRunmemory: runId, statehas manyLogEntrymemory: last 1,000
Only pens and revisions are shared. Drafts survive a crash on this device, and runs and log entries die with the run. baseRevision is the one link between the two sides.

The pen API

The server side is a small REST API. Saves send the full pen with the revision they were based on; if someone saved from another tab in the meantime, the server answers 409 Conflict with the newer revision, and the editor asks which to keep. Forking copies a revision into a new pen owned by the caller.

http
GET  /api/pens/xkqlw2pd              -> 200 Pen (latest revision)
GET  /api/pens/xkqlw2pd/revisions/12  -> 200 Pen (that revision, cached forever)
PUT  /api/pens/xkqlw2pd
     { "baseRevision": 12, "panels": {...}, "packages": {...} }
     -> 200 { "revision": 13 }
     -> 409 { "error": "stale", "latest": 14 }
POST /api/pens/xkqlw2pd/fork          -> 201 { "id": "p9ft2c1m" }
GET  /api/pens/xkqlw2pd/revisions     -> 200 { "items": [...], "next": "cursor" }

The frame protocol

The second interface is the postMessage protocol between editor and preview. Keep it small and versioned, with a type field on every message, so a runner page cached by an old embed still understands a newer editor.

DirectionMessagePurpose
Preview to editorready { v }Runner loaded and listening; protocol version.
Editor to previewrun { runId, html, css, js, map, importMap }Build and start this run.
Editor to previewcss { runId, css }CSS-only update, no reload.
Editor to previeweval { runId, id, code }An expression typed in the console.
Preview to editorconsole { runId, entries }Batched log entries and errors.
Preview to editorheartbeat { runId }Every 250 ms, for the watchdog.
Preview to editorloop-stopped { runId, loopId }A guard broke a loop.

Check your understanding · Exercise 10

Where should each piece of state live? Select all that apply.

Saving, forking, sharing, and embeds

Saving has two layers with different jobs. The draft protects against losing work: every change is written to IndexedDB about a second after typing stops, and flushed right away when the page becomes hidden (visibilitychange), which is the last event a phone reliably gives before it kills a tab. The save publishes: it sends the pen to the API and gets a new revision number. The indexeddb lesson covers the storage API; here the interesting part is what happens when the two disagree.

Each draft records baseRevision, the revision it started from. When the editor opens a pen and finds a draft, there are three cases.

The draft's base equals the server's latest: offer to restore it. The server moved on (you saved from another device): show both and let the user pick. No draft: load the server copy. A pen that was never saved lives only as a draft with a local id until the first save gives it a real one.

Saving as a sequence diagram between the editor, IndexedDB, and the pen API. The editor writes a draft to IndexedDB about a second after typing stops, and flushes it when the page is hidden. The user presses Save: the editor sends PUT with base revision 12, and the API answers 200 with revision 13. Later the editor sends PUT with base 13, but another device already saved, so the API answers 409 with latest 14. The editor keeps the draft in IndexedDB and asks the user which version to keep.EditorIndexedDBPen APIdraft, 1 s after typingflush on hiddenPUT pen, base 12200: revision 13PUT pen, base 13409: latest is 14keep draft, ask user
The failure path loses nothing: on 409 the draft stays in IndexedDB and the user chooses. Drafts never need the network, so the same flow works on a train.

Forks, revisions, and URLs

  • Revisions are immutable. Each save makes revision n plus 1. Old revisions are read-only and cacheable forever, which makes "view history" and "restore" cheap.
  • Forking copies a revision, not a live pen, and records forkedFrom. The fork gets a new id, and so a new preview subdomain: the fork's localStorage starts empty and cannot read the original's.
  • URLs carry the view: /ana/pen/xkqlw2pd for the editor, /ana/full/xkqlw2pd for the preview alone, /ana/embed/xkqlw2pd for embeds, and /ana/pen/xkqlw2pd/12 for a revision.
  • Quick shares without an account can put the compressed source in the URL fragment (after #), which browsers do not send to the server. URLs get long fast, so this suits small pens only; the excalidraw guide uses the fragment differently, to carry a decryption key for an uploaded scene.

Embeds on other sites

An embed is an iframe snippet a blogger pastes: pen.example.com/embed/xkqlw2pd. That page must be framable by any site, so it is served with Content-Security-Policy: frame-ancestors *, while the full editor sends frame-ancestors 'none' so nobody can frame it and trick a logged-in user into clicking Delete (the clickjacking-and-iframes lesson).

The embed page is read-only: it shows the code tabs and a nested preview frame from xkqlw2pd.penrun.io with the same sandbox flags, and every action that changes data opens the full site in a new tab. Safari blocks third-party cookies and Firefox partitions them, so the embed should not count on our cookies anyway.

Embeds are where most views happen, so they must be light. The snippet uses loading="lazy" so embeds far down a post do not load at all until scrolled near. Heavy pens default to click-to-run: the embed shows the code and a static screenshot until the reader presses Run, so a blog with five WebGL demos does not melt a phone.

Pair mode

Collaboration reuses ideas from the google-docs guide. That guide picks operational transformation (OT) for large documents with many editors. For 2 to 6 people on small panels, the off-the-shelf conflict-free replicated data type (CRDT) it names, Yjs, is the simpler choice. Each panel's text becomes a shared Yjs document bound to CodeMirror, synced through a WebSocket room, with remote cursors.

One decision is specific to playgrounds: sync the sources, never the preview. Each participant runs the pen in their own preview frame from the merged sources, so nobody's code runs in anybody else's editor origin and the sandbox story does not change.

Check your understanding · Exercise 11

A user edits a pen on a train, loses the connection, and closes the laptop. Which parts of the design protect the work? Select all that apply.

Performance, accessibility, and hardening

Loading only what a pen needs

The pen page's main content is the code, so render it first as highlighted static HTML from the server. That is the Largest Contentful Paint (LCP), and it does not need the editor at all. The editor bundle loads right after and swaps in, keeping the scroll position. The code-splitting lesson covers the mechanics; the splits that matter here are:

  • Core: CodeMirror's view and state plus the HTML, CSS, and JS language packages, shared by all three panels.
  • Per language: the Sass, TypeScript, Markdown, and Pug editor modes, loaded only when a panel uses them.
  • Per compiler, in the worker: Sass, Sucrase, a Markdown converter. A plain HTML, CSS, and JS pen downloads zero compilers.
  • Embed bundle: a separate, much smaller entry with read-only code views and no editing features at all.
  • Warm-up: preconnect to the pen's preview subdomain and to the CDN, and start the worker while the user reads, so the first run does not pay for connection setup.

While typing, the budget is Interaction to Next Paint (INP): each keystroke should cost the main thread well under 50 ms. The editor's own work is a few milliseconds; everything else (compiling, building documents, serializing logs) happens in the worker or the preview. The console uses a virtualized list, so 1,000 entries cost as little as 30 visible ones. Each removed frame takes its whole page with it, so long sessions do not leak; inside the runner, blob URLs are revoked once loaded.

Accessibility

  • No keyboard trap. Users expect Tab to indent in a code editor, but a focusable box you cannot Tab out of fails WCAG's no-keyboard-trap rule. CodeMirror's documented escape hatch: press Escape, then Tab, to move focus out. Its default keymap also binds Ctrl-M (Shift-Alt-M on macOS) to toggle a mode where Tab moves focus. Show this hint near the editor, because nobody finds it alone.
  • Name every region. Each panel is a labelled region ("HTML editor", "CSS editor"), the iframe has title="Preview", and keyboard shortcuts move between panels (the keyboard-navigation lesson covers roving focus and shortcuts).
  • The console is a log. Give its list role="log", which is a polite live region. Announce errors and a count ("3 new messages"), not every line, or a pen that logs 60 times a second makes a screen reader unusable.
  • Errors reach everyone. Gutter marks have text alternatives, and a "next error" shortcut jumps to the mapped line.

Hardening the editor itself

The preview is isolated, but the editor still shows text that strangers wrote: pen titles, descriptions, comments, and console output. All of it renders as text, never as HTML (the xss lesson). The editor sends a strict CSP (the csp lesson): script-src with our own origin and nonces only, frame-src limited to the preview site, and frame-ancestors 'none' everywhere except the embed page.

On the run site, the risk is abuse rather than theft: a fullscreen pen can pose as a bank's login page. That is why the run site is a separate domain whose reputation does not touch ours, why fullscreen pages carry a small "made on our playground" bar, and why every pen has a report button.

Check your understanding · Exercise 12

Which changes improve the experience without weakening isolation? Select all that apply.

Running it in the interview

In 45 to 60 minutes you cannot cover all of this, so lead with what makes the problem special. Spend about 5 minutes on scope, and make sure the interviewer hears "strangers' code runs next to logged-in users" early. Then draw the two sites and the iframe before anything else: that drawing anchors the whole conversation.

MinutesWhat to doWhat to draw or say
0 to 5Scope: who runs the code, how live, which languages, embeds, packages.The non-functional list, isolation first.
5 to 15Isolation: separate site, per-pen subdomain, sandbox flags.Two origins and the iframe; why same-origin plus allow-scripts is fatal.
15 to 27The run pipeline: debounce, worker, runner page, CSS fast path, source maps.The run sequence and its state machine.
27 to 35Loops and console: guards plus watchdog, serializing, message checks.The console flow, with a forged message being dropped.
35 to 45Packages, data model, API, saving and embeds.The import map, the Pen type, the save flow.
45 to 60Deep dives the interviewer picks; performance and accessibility.Bundle splits, the Escape then Tab hint, the console as a log.

Say trade-offs out loud as you go: "I'd rather pay for a second domain than rely on one sandbox flag being right forever." "Loop guards change program meaning, so the limit is visible and adjustable." "Packages come from a CDN straight into the preview, so a bad package can only hurt the pen." These sentences show you see the costs, not just the boxes.

Think first

Follow-up: "Users now want full projects with many files and a dev server. What changes?"

Think first

Follow-up: "Why not just put a strict CSP on the preview instead of a separate domain?"

Think first

Follow-up: "The preview froze the whole tab on a cheap Android phone even though it is cross-site. Why, and what do you do?"

Summary

  • The preview runs strangers' code next to logged-in users, so it lives on a separate site (a registrable domain of its own), one subdomain per pen, inside a sandboxed iframe. The site boundary keeps cookies, same-site requests, and processes apart; the sandbox removes powers like top navigation.
  • Never combine allow-scripts and allow-same-origin for content on the parent's own origin: the frame can remove its own sandbox. The pair is fine, and needed for storage, only because the preview is on another site.
  • Deliver the document through a cached runner page on the preview origin plus postMessage, not srcdoc or blob URLs from the editor, which carry the editor's origin.
  • Debounce runs at about 500 ms, compile in a worker, number every run and drop stale results, keep the last good preview on errors, and swap CSS without reloading.
  • Stop infinite loops with injected guards everywhere and a heartbeat watchdog where the frame has its own process; reopen in safe mode if a run froze the tab.
  • Console entries cross the boundary as serialized previews, batched and capped. Every message is untrusted: check event.source, origin, and runId, validate shapes, and render text, never HTML.
  • Bare imports resolve through an import map to an ESM CDN at versions pinned when the package was added; in-browser bundling and WebContainers are for full projects.
  • Drafts go to IndexedDB within a second and flush on visibilitychange; saves create immutable revisions with a baseRevision check; embeds are framable read-only pages while the editor sends frame-ancestors 'none'.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next