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.
| Question | Answer we assume | What 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, callfetchwith the user's credentials, settop.locationto 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. Typingfor (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
postMessagechannel, 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.
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?
Because timers only fire when the thread is free. A while (true) loop never returns control to the event loop, so a timer scheduled on that thread never runs. If the editor and the preview share a thread, the editor's timer is stuck too. The fix must either run inside the loop itself (injected checks), or run on a different thread or process that can kill the stuck one (a worker you terminate, or a cross-site iframe in its own process that the parent removes).
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?
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.
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.
| Flag | What it allows | Playground needs it? |
|---|---|---|
allow-scripts | Running JavaScript. | Yes. Without it, nothing runs. |
allow-same-origin | Keeping the frame's real origin, so storage and cookies work. | Yes, and safe only because the frame's origin is another site. |
allow-modals | alert, confirm, prompt, and print. | Yes. Beginners debug with alert. |
allow-forms | Submitting forms. | Yes. Form demos are common. |
allow-popups | window.open and links with target="_blank". | Yes, for demos with external links. Popups still need a click, by browser rules. |
allow-pointer-lock | Locking the mouse pointer, for games. | Yes, for game demos. |
allow-downloads | Starting a file download. | Optional. Export demos use it. |
allow-top-navigation | Setting 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-activation | Top 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.
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?
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.
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.
// 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
runIdgoes up on every run. Results from the worker and messages from the preview carry it, and anything with an oldrunIdis 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?
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.
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.
// 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.
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?
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.
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.
// 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).
// 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 logged | What structured clone does | What 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 itself | Copies 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.
// 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.
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.
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?
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.
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).
{
"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?
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.
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.
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.
| Part | Lives in | Responsibility |
|---|---|---|
| Editor panels | Editor page, main thread | Three CodeMirror views; report changes; show error marks in the gutter. |
| Pen store | Editor page, main thread | The current pen: sources, languages, packages, settings, dirty flag, and revision. |
| Run controller | Editor page, main thread | Debounce, runId, the run state machine, creating and removing preview frames, the watchdog. |
| Build worker | Dedicated Web Worker | Load compilers on demand, compile, insert loop guards, chain source maps, map error positions. |
| Runner and runtime | Preview frame on the run site | Accept files from the editor only; build the document; wrap console; send heartbeats; break long loops. |
| Console | Editor page, main thread | Validate messages, keep the last 1,000 entries, render them as text, evaluate typed expressions in the preview. |
| Draft store | IndexedDB in the editor origin | Autosave every change within about a second; recover after a crash. |
| Pen API client | Editor page | Load, save, fork, and list revisions over HTTPS. |
| ESM CDN | Third party | Serve 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 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.
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.
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[] };
| State | Where it lives | Why there |
|---|---|---|
| Saved pen and revisions | Server; public pens cached on the CDN for 60 s | The source of truth, shared by link. |
| Pen being edited | In-memory store in the editor | Changes on every keystroke; panels and the run controller read it. |
| Draft | IndexedDB, keyed by pen id | Survives a crash or closed tab; holds baseRevision to detect a newer save elsewhere. |
| Compiled output and maps | Build worker memory, keyed by source hash | Skip recompiling unchanged panels. |
| Run state and console | Editor memory, keyed by runId | Dies with the run; never saved. |
| Layout and pane sizes | localStorage in the editor origin | A per-device preference. |
| Pen id, view (editor, full, embed) | URL | Shareable and bookmarkable. |
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.
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.
| Direction | Message | Purpose |
|---|---|---|
| Preview to editor | ready { v } | Runner loaded and listening; protocol version. |
| Editor to preview | run { runId, html, css, js, map, importMap } | Build and start this run. |
| Editor to preview | css { runId, css } | CSS-only update, no reload. |
| Editor to preview | eval { runId, id, code } | An expression typed in the console. |
| Preview to editor | console { runId, entries } | Batched log entries and errors. |
| Preview to editor | heartbeat { runId } | Every 250 ms, for the watchdog. |
| Preview to editor | loop-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.
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'slocalStoragestarts empty and cannot read the original's. - URLs carry the view:
/ana/pen/xkqlw2pdfor the editor,/ana/full/xkqlw2pdfor the preview alone,/ana/embed/xkqlw2pdfor embeds, and/ana/pen/xkqlw2pd/12for 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:
preconnectto 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope: who runs the code, how live, which languages, embeds, packages. | The non-functional list, isolation first. |
| 5 to 15 | Isolation: separate site, per-pen subdomain, sandbox flags. | Two origins and the iframe; why same-origin plus allow-scripts is fatal. |
| 15 to 27 | The run pipeline: debounce, worker, runner page, CSS fast path, source maps. | The run sequence and its state machine. |
| 27 to 35 | Loops and console: guards plus watchdog, serializing, message checks. | The console flow, with a forged message being dropped. |
| 35 to 45 | Packages, data model, API, saving and embeds. | The import map, the Pen type, the save flow. |
| 45 to 60 | Deep 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?"
The pen becomes a file tree, so the data model stores a map of paths to contents and the editor gains a file explorer. Imports between files need resolution: a service worker on the run site can serve each file at its path from memory, or an in-browser bundler like esbuild-wasm can bundle the tree in the worker.
For real Node tooling (npm install, Vite, a terminal), WebContainers run a Node-compatible runtime in the tab, at the cost of cross-origin isolation headers and a heavy download. The alternative is a server-side container per session, which costs money per user. Either way, the isolation rule stays: the running app lives on its own site.
Think first
Follow-up: "Why not just put a strict CSP on the preview instead of a separate domain?"
Because the preview's whole purpose is to run arbitrary inline script, so script-src must allow it; CSP cannot tell the author's code from an attacker's. CSP is still useful on the run site for frame-ancestors and on the editor to keep its own scripts locked down. But the boundary that stops cookie theft, same-site requests, and freezing the editor is the site boundary, not a header.
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?"
On low-memory devices, browsers may not give each cross-site frame its own process, so the preview and the editor share a main thread and the watchdog's timer never fires. Loop guards are the only defense there, which is why we keep them even with isolation. Add the safe-mode flag: on reload, the pen opens with auto-run off and a notice, so the user can fix the loop.
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-scriptsandallow-same-originfor 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, notsrcdocor 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, andrunId, 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 abaseRevisioncheck; embeds are framable read-only pages while the editor sendsframe-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.
References
- MDN: the iframe element Every sandbox token,
srcdoc, and the warning aboutallow-scriptswithallow-same-origin. - web.dev: Understanding same-site and same-origin Why a subdomain is a different origin but the same site.
- Public Suffix List How a domain can make each of its subdomains a separate site.
- Chromium: Site Isolation How Chrome puts different sites in different processes, and where it does not.
- CodePen blog: Changed domains for iframe previews Why CodePen runs previews on a separate domain, cdpn.io, instead of a subdomain.
- CodePen blog: You can adjust the infinite loop protection timing CodePen's injected loop checks and their timing values.
- MDN: Window.postMessage() Target origins and checking
event.originandevent.source. - MDN: The structured clone algorithm What can and cannot cross a
postMessage. - MDN: Import maps Mapping bare specifiers to URLs in the browser.
- CodeMirror: Tab handling Why the editor does not trap Tab, and the Escape then Tab escape hatch.
Concepts behind it
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.