daily

2026-06-08
1

How's Linear so fast? A technical breakdown

Hacker News · original →
How's Linear so fast? A technical breakdown A few milliseconds is all it takes to update an issue in Linear. A traditional CRUD app doing the same thing takes about 300ms. How do they do it? There's…

How's Linear so fast? A technical breakdown A few milliseconds is all it takes to update an issue in Linear. A traditional CRUD app doing the same thing takes about 300ms. How do they do it? There's no secret silver bullet to performance. The reality is that it's built from the ground up on the right foundation, then improved by countless decisions. My goal is to walk through some of the techniques that make Linear feel the way it does and help you implement the same. What I'll cover Database in the browser Making the first load feel instant The sync engine Designed for speed Animations A quick disclaimer: I've never worked at Linear and have never seen their code. Everything I share comes from my personal experience, studying their app, reading their blog posts, or watching their conference talks. I simply love building web apps and have been using Linear since their beta launch. Also, the article’s hero image comes from a video by Meg Wayne, whose work for Linear is phenomenal. Database in the browser Most web apps live inside the same loop. The user clicks. The browser fires an HTTP request. A server queries a database and sends it back. The browser repaints. The end result is a spinner, a skeleton, or a frozen UI for a few hundred milliseconds while the app waits on the network. Linear inverts the traditional relationship. The actual database the UI reads from is in the browser, in IndexedDB. Mutations apply locally first, then asynchronously push to the server, which broadcasts deltas back to other clients via WebSocket. In my opinion, this is the most critical piece to Linear's performance. When your goal is to build a fast web app the biggest bottleneck you will fight is the network. Any data sent between the client and server costs hundreds of milliseconds. The best approach is to eliminate the need for a network request entirely: which is exactly what Linear does. I'll be repeating this a lot, but the secret to building incredible web apps is by hiding all the network requests from the user. The more loading states you can avoid the better. Here's an example of how simple Linear's requests are: // A traditional web app updating the server async function updateIssue({ issue }) { showSpinner(); const response = await fetch(`/api/issues/${issue.id}`, { method: "PATCH", body: JSON.stringify({ title: issue.title }), }); const updated = await response.json(); setIssue(updated) hideSpinner(); } // vs Linear issue.title = "Faster app launch"; issue.save(); The first line, issue.title = "Faster app launch" , updates an in-memory datastore (MobX observable in Linear's case) . The second line, issue.save(); , queues a transaction that their sync engine batches and flushes to the server. The key here is that the UI re-renders synchronously off the local, in-memory, update. There are no spinners because there is nothing to wait for because the data is synced in the backround. This is the magic of treating the browser as the database for each user. Tuomas, one of Linear's co-founders, said this at a conference in 2024: 'Literally the first lines of code that I wrote was the sync engine, which is very uncommon to what you usually do when you're a startup.' From day one, Linear knew the approach they wanted to take and the tradeoffs it would take. I know most people won't build a custom sync engine like Linear just to make their app feel fast and they don't need to. For most use cases, libraries like Tanstack Query and SWR can get surprisingly close with optimistic updates. Most web apps feel slow because the UI waits for each network request to complete before updating state. For most usecases the network request will succeed so you should take advantage of that and optimistically update your state. // optimistic mutation with SWR mutate( `/api/issues/${issue.id}`, { ...issue, title: "Faster app launch" }, false ); // vs Linear issue.title = "Faster app launch"; issue.save(); The key idea is simple: UI responsiveness should not depend on network latency. Users perceive speed based on how quickly the interface reacts, not how quickly the server responds. Optmistic requests is one of the highest leverage improvements you can make: eliminate unnecessary spinners update state immediately validate in the background rollback only if needed Linear's foundation is based on this exact principal and it makes the app feel native and fast. A peek into Linear's stack Linear is built on the simplest stacks you can find: React, TypeScript, MobX, Postgres, a CDN. There's no edge database, no React Server Components, or no fancy framework. Frontend React + react-dom (UI runtime) MobX (observable graph, granular re-renders) TypeScript (single language end-to-end) Rolldown-Vite + plugin-react-oxc(mid-2025; previously Rollup; previously Parcel) ProseMirror + y-prosemirror (rich text editor; Yjs CRDT for live collab) Radix UI primitives (popovers, menus, focus traps) Emotion + StyleX (Emotion runtime + StyleX compiled to atomic CSS) Comlink (Worker RPC) idb (IndexedDB wrapper backing the local-first store) graphql-request (GraphQL transport to the sync server) Sentry (error monitoring) Inter Variable (single woff2, font-display: swap) Backend Node.js + TypeScript (single language for all server code) PostgreSQL on Cloud SQL (issues table partitioned 300 ways) Memorystore Redis (event bus + cache + sync cursors) turbopuffer (similar-issue detection, vector db) Kubernetes on GCP (one workload per concern) Cloudflare Workers (multi-region edge proxy) Other clients Desktop: Electron (same web JS, native chrome) Mobile: Swift (iOS) + Kotlin (a separate full reimplementation) Marketing Next.js (static) styled-components Inline SVG sprite The biggest standout to me is their decision to stick with client-side rendering. CSR often gets criticized for slow initial loads, but with the right architecture and design it can feel instant. I'm also a big fan of the simplicity it brings. Keeping the app entirely client-side creates a much cleaner mental model and removes a lot of the complexity that comes with server-rendered apps. You don't have to constantly think if you're on the server or client. If window object is accessible or not. If you're setting the right cache headers or not. There's beauty in simplicity and the constraints you're forced into. So how does Linear make their client side rendered app feel instant? Making the first load feel instant One thing I obsess over is the first load, and Linear clearly does as well. For productivity tools especially, the time it takes before you can actually start working is one of the most important details to consider. No one wants to be waiting for a new tab to load for multiple seconds First, you have to understand what makes initial loads slow. For a client side app you have to request the index.html , then that requests all the JavaScript and CSS, which then runs some sort of authentication, and finally makes some API requests to show the app. Linear's bundler arc: Parcel, Rollup, Vite, Rolldown The first step to making an app feel instant happens long before runtime. It starts at build time. Remember, the network is the bottleneck, so shipping the least amount of JavaScript and CSS is critical to fast load times. From what I can gather Linear has rewritten their build pipeline four times: Parcel → Rollup → Vite → Rolldown. Each migration was driven by the same goal: reduce the amount of JavaScript and CSS and improve the developer experience. From their own blog posts they claim: 50% less code shipped. 30% smaller after compression. Cold-cache page loads got 10 to 30% faster. Time-to-first-paint of the active-issues view dropped 59% (on Safari). Memory usage dropped 70 to 80% Most of that came from a combination of decisions targeting only modern browsers, better dead-code elimination, and aggressive code splitting. Dropping legacy support is the big win (no polyfills, no ES5 transpilation, no nomodule fallback) but the dead-code and chunking work matters just as much. Even with all of these optimizations, Linear still ships a substantial amount of code: roughly 21 MB of minified JavaScript. The difference is that it's aggressively code split into hundreds of route-level chunks that are fetched on demand. // vite.config.ts (reconstruction; matches observed chunk graph) export default defineConfig({ plugins: [react()], build: { target: "esnext", // no legacy syntax, no polyfills cssMinify: "lightningcss", modulePreload: { polyfill: false }, rollupOptions: { output: { // One chunk per npm package > ~3 KB. Cache invalidation // becomes per-library instead of per-app-revision. manualChunks(id) { if (id.includes("node_modules")) { const pkg = id.match(/node_modules\/([^/]+)/)?.[1]; if (pkg) return `vendor-${pkg}`; } }, }, }, }, }); The lesson isn't which bundler to pick but the importance of dropping legacy browsers, going native ESM, and code splitting like crazy. Each step is small. Stacked, they cut Linear's first-load JavaScript roughly in half and their build time by an order of magnitude. So, the first secret to instant load times is reducing the amount of JavaScript and CSS needed to render something for the user. Preloading after initial load Once you've split your JavaScript into the smallest chunks possible you can start doing work in the background. But hold on, splitting the bundle into hundreds of chunks creates a new problem. Each chunk imports other chunks, and the browser doesn't know what those are until it parses the entry script. Without help, the load timeline becomes a waterfall: fetch the entry, parse it, fetch its imports, parse those, fetch their imports. Every level adds a network round-trip, which you want to avoid at all costs. What Linear does is before any JavaScript runs, the browser sees the entire list and fires off the requests in parallel. By the time the entry script reaches its first import , the chunks are already in cache. Here's what it looks like in the if their index.html The crossorigin attribute on each preload matches the crossorigin on the entry script, so the browser reuses the cached fetch instead of treating preload and import as separate resources. Same trick as the font preload, applied to every chunk on the critical path. The cold-load timeline collapses from a sequential waterfall into a single parallel batch. The network still does the work. It just does it all at once. The beauty of this technique is you're able to do all this work in the background when the user first hits the login page. In a few seconds the full app is stored in cache and served instantly. It's extremely important to understand how people will use your app. Once you have this understanding you can start using it to your advantage, such as preloading scripts in the background as Linear does. The service worker for even more speed and offline capabilities The rest of the Linear, the route-level chunks for views the user hasn't visited yet, gets cached in the background by a service worker. The worker has a precache manifest baked into its source, around 1,200 hashed assets covering route chunks, icons, and fonts, and pulls them down lazily after the first page load. Within a few seconds of hitting the login screen, the full app is sitting in cache. This buys two things. Subsequent navigations skip the network entirely; the service worker answers directly from its cache without even going through HTTP cache. And the app keeps working when the network doesn't. Combined with the local-first sync engine (which already has the user's data in IndexedDB), Linear is usable offline. You can read issues, create new ones, edit titles and descriptions, change statuses. Everything queues in the local transaction store and flushes the next time the connection comes back. Modulepreload is for what the app needs now, parallel-fetched so the browser never blocks on a serial import chain. The service worker is for what the app needs next. So, to get load times fast the steps for Linear is to elminate as much code as possible, split it into small pieces, and precache it in the background. Again, the goal of all this work is to make network requests as fast as possible or, even better, eliminate them completely. Vendor bundle composition I found it interesting that every package Linear uses gets its own chunk, cached independently. A traditional vendor.js invalidates the entire dependency graph on any bump. Linear's chunking turns vendor caching from a single massive file to fine-grained. Bumping a single dependency invalidates one chunk; the rest stay cached. Seems like a no-brainer and yet another detail to ensure fast load times. Loading massive font files Font loading is one of those details a lot of apps get wrong. The failure modes are visible: invisible text for half a second, layout shifts as the real font swaps in, double-fetched resources because the preload didn't match. Linear's setup avoids all three: @font-face { font-family: "Inter Variable"; font-weight: 100 900; font-display: swap; src: url(https://static.linear.app/fonts/InterVariable.woff2?v=4.1) format("woff2"); } /* Italic and Berkeley Mono follow the same shape, single woff2 each. */ Variable fonts cover the full 100–900 weight axis in a single woff2, eliminating per-weight requests. font-display: swap renders the fallback stack immediately and swaps to Inter when it loads. The trick that's easy to miss: crossorigin="anonymous" on the preload tag. Without it, the browser preloads the font, then fetches it again when CSS later references it, because the two requests have different CORS modes. crossorigin on the preload makes the browser reuse the cached one. This all seems simple, but I'm always surprsied at how many apps load fonts incorrectly. Linear is a great example of thinking through the details and ensuring font loading is as fast and accurate as possible. Inlined app shell Another key technique to make the first load feel fast: Inlined in is just enough CSS to paint the loading state with no external stylesheet fetched. Remember, the network is the bottleneck and what you'll always be fighting to make your app feel fast. In this case, Linear elminates a network request by inlining the critical CSS required to show the user an app shell. Beyond CSS there is also a bunch of inlined JavaScript that's critical to loading the initial experience. Before any bundle has parsed, the JavaScript from index.html reads localStorage.splashScreenConfig , merges any sessionStorage override on top, and applies the user's remembered shell tokens directly to document.documentElement.style : sidebar background, base color, border color, sidebar width, agent toolbar height. It detects color-scheme preference and Electron context. It checks whether localStorage.ApplicationStore exists, and if not, adds a logged-out class that switches the shell to the auth layout. By the time the first JavaScript bundle comes from the network the loading screen is already correctly themed, sized, and positioned for whether the user is logged in. This gives the user the feeling that the app is ready to go as soon as they hit enter in the URL bar. There's no faster way around this than sending down the initial app shell in the initial index.html response. Render first, authenticate second Authentication is another step where most apps give up their performance budget. The conventional flow: fetch the HTML, load the bundle, validate the session, fetch the user, fetch the workspace, then render. One to three seconds before the user sees anything. Linear treats auth the same way it treats mutations. Assume the happy path and verify in the background. This is probably one of my favorite parts of their architecture because it allows them to almost immediately render the full experience on load. Most CRUD apps keep the real session in an HttpOnly cookie, then add a second JS-readable cookie or /me request so the frontend can tell whether the user is logged in during startup. Linear does something simpler. Instead of maintaining a parallel auth signal, the inline boot script just checks whether localStorage.ApplicationStore exists: if (localStorage.getItem("ApplicationStore") === null) { document.documentElement.classList.add("logged-out"); } If it's there, the user has used Linear in this browser before, which means their workspace is already sitting in IndexedDB. This goes back to the first section we covered where the database lives in the browser. If it's missing, there's nothing to render anyway, so the shell flips to its logged-out layout and the login flow takes over. The initial flow for Linear isn't "do you have a valid session." It's "do we have anything to show you." Their actual session token sits in a cookie. The bundle never tries to be smart about it. It just renders what it has and lets the next request (the WebSocket handshake, a sync delta, any HTTP call) be the thing that fails with a 401 if the session has gone stale. When that happens, the client redirects to login. The whole pattern is consistent with the rest of the architecture: the client trusts what's local, the server is the source of truth for correctness, and the two reconcile asynchronously. Just like a mutation. Just like their sync engine. This is maybe one of my favorite details about Linear that I wish more apps behaved this way. For authentication, assume happy path, and fallback if not. If there's data to be shown: show it! And leverage your browser's datastores to render immediately. The sync engine Most of what makes Linear fast lives downstream of one decision: the server is a sync target, not a source of truth for the UI. The internals of their sync engine been thoroughly reverse-engineered already, and Tuomas has given multiple excellent talks on the architecture. I'm not going to retrace them. What I want to do is name the three pillars that actually produce the speed, because the speed is a property of how they fit together, not of any single one. 1. The data is already there When the app boots, it doesn't fetch the workspace from the server. It hydrates from IndexedDB into an in-memory MobX object pool, and every query from the UI goes to the pool first. There's no "loading issues" state because the issues are already on the user's machine. Something I found interesting is as they've scaled they've chunked the data in the sync enginer using the similar fundamentals as their JavaScript bundles. Not everything is fetched at once: the two heaviest tables, Issue and Comment, lazy-hydrate on demand. This is data-level code splitting, and it's what lets the engine scale: startup cost tracks the workspace structure, not the workspace size. A 10,000-issue workspace boots about as fast as a 100-issue one. Click into a project, the issues are there. Filter by assignee, the index is already built. There's nothing to fetch because there's nothing missing. It's either been immediately loaded from your browser or shortly after in a codesplit lazy chunk. 2. Mutations don't wait for the network When you change an issue's status, three things happen almost at once: the MobX observable updates so the UI reflects the change, the mutation is written to a durable transaction queue in IndexedDB, and it's queued for the server. The network hasn't been touched yet. The user never waits to see their own change. The retry, the rollback, the durability across reloads, all background. If the server rejects, the observable reverts and there's a brief flicker, but in practice that almost never happens because most invalid mutations are caught before the transaction is even created. As I keep saying: the network is the enemy and you must do everything you can to avoid it. Linear's flow starts with the local mutation and treats the server as a confirmation step, not a permission step. 3. One delta, one cell When the server confirms a mutation (yours or someone else's), the change comes back as a small JSON envelope describing what moved. The client applies it by writing to the corresponding MobX observable. Because every property on every model in Linear is its own observable, and every component that reads one is wrapped in observer() , MobX knows exactly which components depend on which fields. A change that updates one field of one issue re-renders exactly the components that read that field. Not the parent list, not the sidebar, one cell. A 50-issue update is 50 cell re-renders, not a list re-render. This is what lets a busy workspace stay smooth when ten people are editing things at once: the cost of receiving updates scales with what changed, not with what's on screen. I've built real-time apps streaming in stock data and fundamentals and having atomic updates of individual components it key to making an app feel performant. You want to avoid cascading updates as much as possible and Linear does exactly that. Why the three fit together Take any one away and the app starts to feel slow. A local database without optimistic writes still spins on save. Optimistic writes without granular observables still jank on every update. Granular observables without a local database still wait on initial load. Linear's speed isn't a property of any single layer. It's a property of the system. The bundler and loader shell are what make the app feel fast on first paint. The sync engine is what keeps it feeling fast once you start using it. Designed for speed Speed isn’t just an engineering problem. It’s a design problem too. A perfectly built sync engine still loses to a slow input model: if the fastest path to an action requires a mouse, three menus, and a click, the user pays for those steps regardless of how fast the underlying engine runs. Another cornerstrone to Linear's speed is how they've intergarated the keyobard as a priamry tool to navigate and complete your work. Every common action has a shortcut. The command palette is one keystroke away. The right-click menu is custom-built. None of these are accidents but instead thoughtful design decision from day one. Every action has a shortcut Single letters edit the focused issue. Two-letter combos navigate. Modifiers act globally. Listening to the founders talk about Linear’s early days, it’s clear that shortcuts were foundational from the start. The sync engine was designed in part so that any action could be performed at any time. It feels like this combination of design and engineering is continues to be behind every feature. If you look through their UI you'll notice shortcuts visible everywhere. The most frequent ones are single characters as they're used the most often. Furthermore, every action can be done with a mouse as not to alienate beginners. The command palette is always one keystroke away ⌘ k opens a command palette that lets users search over almost any action in Linear. Issues, projects, labels, status changes, navigation, issue creation, settings, theme toggles. The command is incredibly fast because it's searching the local MobX object pool, not a server. Remember, avoid the network. The architectural payoff is that the entire app is accessible from a single pane. Navigation is search. Issue creation is search. Status changes are search scoped to statuses. Moreoever, the command is contextual and adapts to the what you're working on. A great way to teach key actions and shortcuts for any view. One primitive, used everywhere, running on data that's already in memory. A fast app needs both incredible engineering and design. You can build a perfect sync engine and a flawless rendering pipeline, and still ship something that feels slow if the design is wrong. Engineering speed makes a single interaction fast. Design speed makes the path to each interaction short. For a tool used all day, the difference between a shortcut and a two-second mouse path compounds over every action. Combine shortcuts with a global commmand palette and you've got yourself an app that's incredibly fast to use. Animations All the work up to now can still be undone by bad animations. Teams spend enormous effort making every part of their app fast. Initial load, updates, database queries, all of it. They shave off milliseconds so users never have to wait. Then, at the very last step, someone adds a 500ms height animation to an element. There are only a handful of properties you should animate Browsers have three tiers of property changes, and the cost scales with how high each one is on the rendering pipeline. Composited properties (transform , opacity ) hand the work to the GPU and run independent of the main thread. Paint-triggering properties (color , background-color , border-color , fill ) skip layout but still redraw pixels. Layout-triggering properties (width , height , top , left , margin , padding ) force the browser to recompute the position of every subsequent element on the page. Never animate those. I mean never. /* What Linear does */ .row:hover { background-color: var(--color-bg-hover); transition: background-color 0.12s; } .icon-arrow { transform: translateX(0); transition: transform 0.15s; } /* What you'd write if you didn't know better */ .row:hover { margin-left: 2px; /* triggers layout for every row beneath */ transition: all 0.2s; /* and now you're animating margin */ } The margin-left version recomputes the layout of every row beneath the hovered one, on every frame, for the full 200ms of the transition. On a long issue list that's the difference between buttery and jank. If you go over every single property Linear animates in their app it's reserved to a handful, mostly those composited properties (transform and opacity ) and sometimes properties like background-color and border-color . Know when to hold back In my opionion, what's almost as important as only animating composite properties is knowing when to not animate at all. It's easy to get carried away with animations. But in a tool used every day, the animations you'd love on a marketing site start to get in the way. Even a small hover delay, in the wrong place, becomes the thing the user notices. Linear nails most of this. The command palette is the one I'd argue is too slow, but I've become a cranky old man over the years. The reason a lot of their animations work is that they reference their origin. The status popover scales out of the status pill. The agent panel slides in from its toggle. The motion is doing spatial work, telling the user where the new element came from, rather than fading in from nowhere as decoration. Keep durations short and snappy /* variables form Linear's stylesheet */ --speed-highlightFadeIn: 0s; --speed-highlightFadeOut: .15s; --speed-quickTransition: .1s; --speed-regularTransition: .25s; --speed-slowTransition: .35s; Most design systems default longer than they should. Material's standard duration is 200ms, iOS's spring closer to 350ms. Defaulting to shorter transitions is one of the easiest ways to make an app feel faster, and Linear's defaults sit well below the industry norm. Linear takes this one step further with asymmetric timing on enter and exit. Hover highlights, popovers, and the agent panel appear instantly when you summon them, then fade out over 150ms when you dismiss them. As a small side note, one of Linear's Design Engineers, Emil Kowalski, created an incredible course at animations.dev. If you found the last couple of sections interesting, it's worth checking out. He dives deep into animation principles with plenty of examples and practical tutorials. How Linear is so fast There are so many more details I could cover that make Linear feel fast. The reality is there's no single thing that makes an app performant. It's the culmination of hundreds of decisions made correctly. What I love about Linear's approach is how simple most of it is. No Next, no Tanstack, no fancy framework. They decided early on what architecture would serve their users best and have stayed true to it. The result is a client-side rendered app that's faster than server-rendered ones (and without the complexity)! The shape of it is roughly this. The server is a sync target rather than a source of truth. The database lives in the browser. Mutations apply locally first and reconcile in the background. The first load ships less code in more pieces, with a service worker precaching the rest while the user is still on the login page. Auth is assumed based off state and verified later. The sync engine hydrates from IndexedDB into per-property MobX observables, so a 50-issue update is 50 cell re-renders rather than a list re-render. The input model is keyboard-first. Every common action has a shortcut with a global command palette. Animations stay on the GPU, durations sit below the 100ms cause-and-effect threshold, and layout-triggering properties are never animated. The hard part isn't the implementation. It's the dedication to the craft over years, as the codebase matures, expands, and pushes up against new constraints. If you haven't, I'd recommend checking out Linear to see it all in action. Hope you learned a thing or two! It was fun writing this and diving into the details that make Linear what it is. I just love building the best web apps in the world and see how other people do it. If you have any feedback, suggestions, or want to connect you can find me on X.

2

datasette-agent-edit 0.1a0

Simon Willison · original →
7th June 2026 I'm planning several plugins for Datasette Agent which can make edits to existing pieces of text - things like collaborative Markdown editing, updating large SQL queries, and editing…

7th June 2026 I'm planning several plugins for Datasette Agent which can make edits to existing pieces of text - things like collaborative Markdown editing, updating large SQL queries, and editing SVG files. Agentic editing of text is a little tricky to get right. My favorite published design for this is for the Claude text editor, which implements the following tools: view - view sections of a file, with line numbers added to every line.str_replace - find an exactold_str and replace it withnew_str - fail if the original string is not uniqueinsert - insert the specified text after the specified line number Rather than recreate these patterns for every plugin that needs them I decided to create this base plugin, datasette-agent-edit , which implements the core tools in a way that allows them to be adapted for other plugins. Recent articles - Running Python code in a sandbox with MicroPython and WASM - 6th June 2026 - Claude Opus 4.8: "a modest but tangible improvement" - 28th May 2026 - I think Anthropic and OpenAI have found product-market fit - 27th May 2026

3

micropython-wasm 0.1a2

Simon Willison · original →
6th June 2026 I added a CLI to micropython-wasm (issue #7), inspired by the first draft of the blog entry when I realized it would be a great way to illustrate the Try it yourself section. Recent…

6th June 2026 I added a CLI to micropython-wasm (issue #7), inspired by the first draft of the blog entry when I realized it would be a great way to illustrate the Try it yourself section. Recent articles - Running Python code in a sandbox with MicroPython and WASM - 6th June 2026 - Claude Opus 4.8: "a modest but tangible improvement" - 28th May 2026 - I think Anthropic and OpenAI have found product-market fit - 27th May 2026

4

Running Python code in a sandbox with MicroPython and WASM

Simon Willison · original →
Running Python code in a sandbox with MicroPython and WASM 6th June 2026 I’ve been experimenting with different approaches to running code in a sandbox for several years now, but my latest attempt…

Running Python code in a sandbox with MicroPython and WASM 6th June 2026 I’ve been experimenting with different approaches to running code in a sandbox for several years now, but my latest attempt feels like it might finally have all of the characteristics I’ve been looking for. I’ve released it as an alpha package called micropython-wasm, and I’m using it for a code execution sandbox plugin for Datasette Agent called datasette-agent-micropython. - Why do I want a sandbox? - What I want from a sandbox - WebAssembly looks really promising here - MicroPython in WebAssembly - Building the first version - Try it yourself - Should you trust my vibe-coded sandbox? Why do I want a sandbox? My key open source projects—Datasette, LLM, even sqlite-utils—all support plugins. I absolutely love plugins as a mechanism for extending software. A carefully designed plugin system reduces the risk involved in trying new things to almost nothing—even the wildest ideas won’t leave a lasting influence on the core application itself. My software can grow a new feature overnight and I don’t even have to review a pull request! There’s one major drawback: my plugin systems all use Python and Pluggy, and plugin code executes with full privileges within my applications. A buggy or malicious plugin could break everything or leak private data. I’d love to be able to run plugin-style code in an environment where it is unable to read unapproved files, connect to a network, or generally operate in a way that’s risky or harmful to the rest of the application or the user’s computer. My interest covers more than just plugins. For Datasette in particular there are many features I’d like to support where arbitrary code execution would be useful. I’ve already experimented with this for Datasette Enrichments, where code can be used to transform values stored in a table. I’d love to build a mechanism where you can run code on a schedule that fetches JSON from an approved location, runs a tiny bit of code to reformat it into a list of dictionaries, then inserts those as rows in a SQLite database table. What I want from a sandbox My goal is to execute code safely within my own Python applications. Here’s what I need: - Dependencies that cleanly install from PyPI, including binary wheels across multiple platforms if necessary. I don’t want people using my software to have to take any extra steps beyond directly installing my Python package. - Executed code must be subject to both memory and CPU limits. I don’t want while True: s += "longer string" to crash my application or the user’s computer. - File access must be strictly controlled. Either no filesystem access at all or I get to define exactly which files can be read and which files can be written to. - Network access is controlled as well. Sandboxed code should not be able to communicate with anything without going through a layer I fully control. - Support for interaction with host functions. A sandbox isn’t much use if I can’t carefully expose selected platform features to the code that it’s running. - It has to be robust, supported, and clearly documented. I’ve lost count of the number of sandbox projects I’ve seen in repos with warnings that they aren’t actively maintained! WebAssembly looks really promising here Web browsers operate in the most hostile environment imaginable when it comes to malicious code. Their job is to download and execute untrusted code from the web on almost every page load. Given this, JavaScript engines should be excellent candidates for sandboxes. Sadly those engines are also extremely complicated, and are not designed for easy embedding in other projects. Most of the V8-in-Python projects I’ve seen are infrequently maintained and come with warnings not to use them with completely untrusted code. WebAssembly is a much better candidate. It was designed from the start to support all of the characteristics I care about and has been tested in browsers for nearly a decade. The wasmtime Python library brings WASM to Python, is actively maintained, and has binary wheels. MicroPython in WebAssembly WebAssembly engines like wasmtime run WebAssembly binaries. Some programming languages like Rust are easy to compile directly to WebAssembly. Dynamic languages like JavaScript and Python are harder—they support language primitives like eval() , which means they need a full interpreter available at runtime. To run Python we need a full Python interpreter compiled to WebAssembly, wired up in a way that makes it easy to feed it code, hook up host functions and access the results. Pyodide offers an outstanding package for running Python using WebAssembly in the browser, but using Pyodide in server-side Python isn’t supported. The most recent advice I could find was from October 2024 stating “Pyodide is built by the Emscripten toolchain and can only run in a browser or Node.js”. The other day I decided to take a look at MicroPython as an option for this. The MicroPython site says: MicroPython is a lean and efficient implementation of the Python 3 programming language that includes a small subset of the Python standard library and is optimised to run on microcontrollers and in constrained environments. WebAssembly sure feels like a constrained environment to me! Building the first version I had GPT-5.5 Pro do some research for me, which turned up this PR against MicroPython by Yamamoto Takahashi titled “Experimental WASI support for ports/unix”. It then produced this research.md document, so I let Codex Desktop and GPT-5.5 high loose on it to see what would happen: read the research.md document and build this. You will probably need to write a script that compiles a custom WASM version of MicroPython as part of this project - fetch the MicroPython code to a /tmp directory for this as part of that script. It worked. I now had a prototype Python library that could execute Python code inside a WebAssembly sandbox! The trickiest piece to solve was persistent interpreter state. The WASM build we are using here exposes a single entry point which starts the interpreter, runs the code and then stops the interpreter at the end. This works fine for one-off scripts, but for Datasette Agent I want variables and functions to stay resident in memory so I can reuse them across multiple code execution calls. A neat thing about working with coding agents is that you can get from an idea to a proof of concept quickly. I prompted: For keeping variables resident: what if we ran code inside micropython itself which called a host function get_next_python_code() and then passed that to eval() - and that host function blocked until new code was available, maybe by running in a thread with a queue? Could that or a similar idea help here? After some iteration we got to a version of this that works! In Python code you can now do this: from micropython_wasm import MicroPythonSession with MicroPythonSession() as session: print(session.run("x = 10\nprint(x)").stdout) print(session.run("x += 5\nprint(x)").stdout) print(session.run("print(x * 2)").stdout) Under the hood this starts a thread, sets up a request queue and then sends messages to that queue for the session.run() command, each time waiting on a reply queue for the result of that execution. Inside WASM the MicroPython interpreter blocks waiting for a __session_next__() host function to return the next line of code, which it runs eval() on before calling __session_result__({"id": request_id, "ok": True}) when each block has been successfully executed. The other piece of complexity was supporting host functions, so my Python library could selectively expose functions that could then be called by code running in MicroPython. Codex ended up solving this with 78 lines of C, which ends up compiled into the 362KB WebAssembly blob I’m distributing with the package. I am by no means a C programmer, but I’ve read the C and had two different models explain it to me (here’s Claude’s explanation) and I’ve subjected it to a barrage of tests. The great thing about working with WebAssembly is that if the C turns out to be fatally flawed the worst that can happen is the WebAssembly execution will fail with an exception. I can live with that risk. Memory limits are directly supported by wasmtime. CPU limits are a little harder: wasmtime offers a “fuel” concept to limit how many operations a WebAssembly call can execute, and that’s the correct fit for this problem, but the units are hard to reason about. I’m experimenting with a 20 million default “fuel” setting now but I’m not confident that it’s the most appropriate value. Try it yourself The micropython-wasm alpha is now live on PyPI. You can try it from your own Python code as described in the README. I’ve also added a simple CLI mode in version 0.1a2 which means you can try it using uvx without first installing it like so: uvx micropython-wasm -c 'print("Hello world")' # To see it run out of fuel: uvx micropython-wasm -c 's = ""; while True: s += "longer"' # Outputs: micropython-wasm: guest exited with code 1 You can also try it in Datasette Agent like this: uvx llm keys set openai # Paste in an OpenAI key, then: uvx --with datasette-agent \ --with datasette-agent-micropython \ --prerelease allow \ datasette --internal internal.db \ -s plugins.datasette-llm.default_model gpt-5.5 \ --root -o Then navigate to http://127.0.0.1:8001/-/agent and run the prompt: show me some micropython You can try a live demo of that plugin running in Datasette Agent by signing into agent.datasette.io with your GitHub account. Should you trust my vibe-coded sandbox? Having complained about immature, loosely-maintained sandboxing libraries, it’s deeply ironic that I’ve now built my own! I deliberately slapped an alpha release version on it, and I’m not ready to recommend it to anyone who isn’t willing to take a significant risk. I’ve put it through enough testing that I’m OK using it myself. I’ve shipped my first plugin that uses it, datasette-agent-micropython. I’ve also locked GPT-5.5 xhigh in that Datasette Agent plugin and challenged it to break out of the sandbox and so far it has not managed to. I’m hoping this implementation can convince some companies with professional security teams and high-stakes problems to commit to using Python in WebAssembly as a sandboxing approach and open source their own solutions. More recent articles - Claude Opus 4.8: "a modest but tangible improvement" - 28th May 2026 - I think Anthropic and OpenAI have found product-market fit - 27th May 2026

5

Microsoft's new MAI models

Simon Willison · original →
2nd June 2026 Microsoft announced two new text LLMs this morning - MAI-Thinking-1 (reasoning, 1T parameters, 35B active, available to "select early partners") and MAI-Code-1-Flash (137B Parameters,…

2nd June 2026 Microsoft announced two new text LLMs this morning - MAI-Thinking-1 (reasoning, 1T parameters, 35B active, available to "select early partners") and MAI-Code-1-Flash (137B Parameters, 5B active, "purpose-built for GitHub Copilot and VS Code to deliver high performance and lower cost [...] rolling out to GitHub Copilot individual users in Visual Studio Code"). I've not been able to try either of them just yet. It's very interesting to see Microsoft releasing models with such low parameter counts, especially given how expensive larger models are to access right now. They claim MAI-Thinking-1 "is preferred to Sonnet 4.6 in our blind human side-by-side evaluations", which is impressive for a 35B model seeing as I frequently run models larger than that on my own laptop. (UPDATE: I got this entirely wrong, see note below.) Also of note: We trained [MAI-Thinking-1] from the ground up on enterprise grade, clean and commercially licensed data, without distillation from third-party models. And for MAI-Code-1-Flash as well: It is built end-to-end by Microsoft using clean and appropriately licensed data. I would very much like to learn more about this "appropriately licensed" data! Could these be the first generally useful code-specialist models that didn't train on an unlicensed dump of the web? (Update: the answer is no, see note below.) Update: My initial published notes got the size of the models wrong. I misread Microsoft's announcements and interpreted the MoE active parameter count as the total parameter count, but the model card for MAI-Code-1-Flash lists it as 137B with 5B active and the MAI-Thinking-1 technical paper reveals it to be a 1T model with 35B active. I deeply regret this error. Update 2: That technical paper describes the training data in some detail from page 80 onwards. It has the same licensing problems as all of the other major LLMs: it's trained on a crawl of the public web: The majority of our web HTML corpus comes from a proprietary crawl. After initial page discovery and selection, approximately 1.2 trillion pages are crawled and parsed. [...] In addition to Microsoft standard policy Sec. 2.4, we apply UT1 block list (Prigent, 2026) to remove adult content and piracy-related domains. In all, this filtering reduces the corpus from 1.2 trillion pages to 794 billion pages. Given the prevalence of AI-generated content on the web, we also score pages with a proprietary AI-content detection model and use manual inspection to identify domains with extensive AI-generated content; those domains are filtered out of the training corpus. [...] We process Common Crawl with the same pipeline. [...] After filtering, deduplication, merging with the proprietary web corpus, and a final round of exact-URL and content-level fuzzy deduplication, the Common Crawl portion contains 24.2 billion pages. I did not cover this one at all well, which is somewhat ironic since I was at the Microsoft Build conference when I wrote this up! I'm sorry for not digging deeper before publishing my initial notes. Recent articles - Running Python code in a sandbox with MicroPython and WASM - 6th June 2026 - Claude Opus 4.8: "a modest but tangible improvement" - 28th May 2026 - I think Anthropic and OpenAI have found product-market fit - 27th May 2026

6

datasette-agent-micropython 0.1a0

Simon Willison · original →
2nd June 2026 Release datasette-agent-micropython 0.1a0 — MicroPython in a WASM sandbox as a tool for Datasette Agent I want Datasette Agent to be able to generate and execute Python code safely.…

2nd June 2026 Release datasette-agent-micropython 0.1a0 — MicroPython in a WASM sandbox as a tool for Datasette Agent I want Datasette Agent to be able to generate and execute Python code safely. This alpha is looking promising so far. GPT-5.5 has so far failed to break out of the sandbox! Recent articles - Running Python code in a sandbox with MicroPython and WASM - 6th June 2026 - Claude Opus 4.8: "a modest but tangible improvement" - 28th May 2026 - I think Anthropic and OpenAI have found product-market fit - 27th May 2026

7

micropython-wasm 0.1a1

Simon Willison · original →
2nd June 2026 Fixes for some limitations that emerged while I was trying to use this to build datasette-agent-micropython . Recent articles - Running Python code in a sandbox with MicroPython and…

2nd June 2026 Fixes for some limitations that emerged while I was trying to use this to build datasette-agent-micropython . Recent articles - Running Python code in a sandbox with MicroPython and WASM - 6th June 2026 - Claude Opus 4.8: "a modest but tangible improvement" - 28th May 2026 - I think Anthropic and OpenAI have found product-market fit - 27th May 2026

8

micropython-wasm 0.1a0

Simon Willison · original →
2nd June 2026 My latest sandboxing experiment: This alpha package bundles a lightly customized WASM build of MicroPython with a wrapper to execute code in it via wasmtime. Recent articles - Running…

2nd June 2026 My latest sandboxing experiment: This alpha package bundles a lightly customized WASM build of MicroPython with a wrapper to execute code in it via wasmtime. Recent articles - Running Python code in a sandbox with MicroPython and WASM - 6th June 2026 - Claude Opus 4.8: "a modest but tangible improvement" - 28th May 2026 - I think Anthropic and OpenAI have found product-market fit - 27th May 2026

9

datasette 1.0a32

Simon Willison · original →
31st May 2026 A minor bugfix release. Fixes a bug with INSERT ... RETURNING queries via the new /db/-/execute-write endpoint and a bunch of base_url issues which showed up when I was experimenting…

31st May 2026 A minor bugfix release. Fixes a bug with INSERT ... RETURNING queries via the new /db/-/execute-write endpoint and a bunch of base_url issues which showed up when I was experimenting with Service Workers yesterday. Recent articles - Running Python code in a sandbox with MicroPython and WASM - 6th June 2026 - Claude Opus 4.8: "a modest but tangible improvement" - 28th May 2026 - I think Anthropic and OpenAI have found product-market fit - 27th May 2026

10

Running Python ASGI apps in the browser via Pyodide + a service worker

Simon Willison · original →
30th May 2026 Datasette Lite is my version of Datasette that runs entirely in the browser using Pyodide in WebAssembly. When I first built it four years ago I used Web Workers and code that…

30th May 2026 Datasette Lite is my version of Datasette that runs entirely in the browser using Pyodide in WebAssembly. When I first built it four years ago I used Web Workers and code that intercepts navigation operations and fetches the generated HTML by running the Python app. This worked, but had the disadvantage that any JavaScript in