Web: a browser for the ESP32-S3
An HTML, CSS and JavaScript engine written from scratch for a microcontroller with 4 MB of flash, 8 MB of PSRAM and about 354 KB of internal SRAM. It presents itself as an EPOC application called Web, which is what the Psion Series 5's own browser was called, and it runs on the same toolkit as the Psion emulation project it grew out of.
It fetches over HTTPS, decodes gzip, parses HTML5, cascades CSS, lays out the whole document, runs the page's scripts and paints the result through a sixteen-entry palette on an 800x480 panel.

A real screen dump off the hardware, taken over the serial line with vendor/diy/tools/screenshot.py. The circularity is deliberate: a browser built on a reimplemented Psion toolkit, reading the Wikipedia article about the Psion.
Why not port an engine
WPE WebKit is the port of WebKit intended for embedded systems, and it was the obvious first candidate. It does not fit, and it does not nearly fit. The reasoning is recorded in docs/architecture.md so that the question stays settled rather than being revisited every few months.
| WebKit assumes | This target has |
|---|---|
| An MMU and a process model. WPE is genuinely multi-process (UIProcess, WebProcess, NetworkProcess) | No MMU. FreeRTOS, one address space |
Dynamic linking; the port loads its backends with dlopen |
A single statically linked image, executed in place from flash |
| Tens of MB of text and data; ICU alone is around 30 MB | 4 MB of flash, 16 MB on the board this is heading for |
| Hundreds of MB of RSS for a blank page; bmalloc reserves multi-GB virtual ranges | 8 MB of PSRAM, around 354 KB of internal SRAM |
| C++ exceptions and RTTI throughout | Both off, deliberately |
| JavaScriptCore: a JIT with W^X mappings, or LLInt, which still assumes a 64-bit-ish machine | 32-bit Xtensa LX7 |
None of those are configuration. Turning off GPU, video, WebRTC and sandboxing addresses the optional parts; the load-bearing parts are the ones in the table. The smallest board WPE realistically runs on is a Pi Zero 2 W with Linux and 512 MB. The gap is roughly thirty times on flash and on RAM, and rather more than that on the assumptions.
The original estimate for writing it instead was 15,000 to 20,000 lines. The engine components are 29,715 lines today, with the application above them another 13,560. Take out the JavaScript engine, which was not in the plan at the time, and the engine is 18,178 lines, which is inside the estimate.
The readable web
The target is articles, documentation, reference material, forums and anything hand-written. Not applications. That is a scope decision rather than an aspiration, and it is what makes the rest of the design tractable.
What follows from it:
- HTTP/1.1 only. HPACK and stream multiplexing are a lot of code to save round trips on a device whose real cost is the TLS handshake, and effectively every server still answers h1.
- A subresource cap of 24 fetches and 2 MB per page, ordered stylesheets first and then images in document order, so that hitting the cap degrades the page rather than corrupting it.
- A page body over 512 KB is truncated and the status bar says so. The BBC front page is 992 KB, so half of it is missing.
- No video, no audio, no canvas, no WebSockets, no iframes.
Images are PNG, baseline JPEG, GIF and a static subset of SVG, decoded a row at a time straight into 4bpp and dithered with an ordered 4x4 Bayer matrix as they arrive. Error diffusion looks better and needs a full-width error buffer and a serial dependency between rows, which is the wrong trade when the source bytes can otherwise be discarded as they land. An SD card mounts at /sd, and file:///x.html reads from it, which is how known bytes are put through the real engine on real hardware.
JavaScript
The architecture document argued that a JavaScript engine would buy less here than it appears to, and put QuickJS at roughly 600 KB of flash. The engine written for this device instead measures 129.9 KB of flash and 26.2 KB of static RAM with everything on, so the cost half of that argument was wrong by a factor of four or five. That figure is from tools/footprint.py on 1 August, before modules and forms landed, and every row of it is a full build with the option turned off rather than an estimate. components/js is 11,537 lines.
What it does:
- The language. Against test262 frozen at ES5.1: 11,255 of 11,255 files lexed and parsed, 26 of 26 deliberate syntax errors refused, and 6,518 of 11,255 run clean (57.9%), up from 34.3% when the suite was first run rather than just parsed.
jsmin.txt, the document that defines the scope of the browser-facing work, is 130 of 130. That is probed rather than ticked: every language and library row is evaluated by the real interpreter and every browser row is looked up in the bindings that would install it, bytools/jsmin_audit.py.docs/the-rest-js.mdis the prioritised backlog of what comes after that, 30 ranked items, and 16 of 30 are built as of today. Every Critical and every Very-high is done: promises with correct microtask ordering, async and await, the full event path including capture and default actions, DOM geometry,getComputedStyle, MutationObserver, ES modules with live bindings and cycles, property descriptors, symbols and iteration protocols, Map and Set,fetchandXMLHttpRequest, AbortController, forms as a coherent subsystem, URL and URLSearchParams.
What it does not do: typed arrays, Blob and File, canvas, Proxy and Reflect, generators, WebSockets, workers, iframes, shadow DOM, custom elements, IndexedDB, WebAssembly, IntersectionObserver and ResizeObserver. Classes are lowered rather than implemented properly, which is enough for most code and not enough for all of it. The backlog is the honest list and it is ordered by what real pages actually use, not by specification chapter.
Two limits worth stating out loud. There is no garbage collector. Script memory is a 1 MB arena and it is given back when the page goes, not before; a repeating timer allocates about 450 bytes a tick, so a busy setInterval exhausts the arena in roughly 110 seconds, at which point the engine stops the timers and says so. And every script runs after the parse, never during it, which is the one thing document.write depends on. Rather than guess whether that matters, document.write is a no-op that records what was written and where. Across the eight sites tested so far, three of which fetch external scripts, it has been called zero times.
What one page costs
Loading https://en.wikipedia.org/wiki/Psion_Series_5 on the board, measured today:
| Fetch | 126,908 bytes after inflate, 26,799 on the wire (21%), 2,715 ms, 0 redirects |
| DOM | 2,305 nodes, 11 style blocks, 101 author rules (22 dropped) |
| Style | 93 distinct computed styles, 18,600 bytes |
| Layout | 2,186 boxes, 3,547 px tall, 680 px wide, 136 KB |
| Scripts | 4 queued, 3 ran (100 ms, 4 ms, 5 ms), 1 returned 304 and was not used |
| Restyle after scripts | 437 ms (style 411, layout 26) |
| Script stack | 81,132 bytes still free at worst |

The same load, captured off the device: the status line reads 200, text/html; charset=UTF-8, 126908 bytes, 2715ms, and above it is Wikipedia's own navigation chrome, which a reader has to scroll past to reach the article.
Three of the four scripts ran and the document changed, and putting that change on the screen cost 437 ms. That is the price of running JavaScript on this hardware, stated plainly: the scripts themselves are cheap and the consequence is not, because layout is whole-document rather than incremental. Incremental layout is a real optimisation and it is much easier to add to a correct engine than to debug inside an incorrect one, so it is not in this version, and the box tree is structured so that it can be.
Caching is worth more than any of it, because a fetch costs one to four seconds almost regardless of size. Measured against the local echo server with one 157-byte stylesheet: 196 ms cold, 44 ms to revalidate with If-None-Match, 9 ms for a plain hit.
Memory is the constraint
PSRAM is the plentiful resource and is not what to watch. Internal SRAM is. There is about 354 KB of it, and the graphics layer used to hold 192,000 bytes of that in one contiguous block for the screen bitmap, with the panel's bounce buffers taking another 25,600.
That left 19 KB free after a page had loaded, and - the number that actually bound - a largest contiguous free block of 7 KB, against a TLS handshake that needs one 8 KB allocation and an mbedTLS session that wants a 16 KB input buffer because few servers honour max_fragment_length. Nineteen kilobytes was stable, nothing was leaking, and it was not comfortable.
Moving the screen bitmap to PSRAM on 31 July ended the whole class of problem: 195 KB internal free with the browser up and WiFi associated, and a largest block of 124 KB rather than 7 KB. Nothing can fragment what has already been taken. It costs 2 to 6 per cent on drawing, measured upstream on the 5.0" panel
- Update 33.8 ms to 35.8, DrawRect 138.4 to 141.4, DrawText 22.4 to 23.5 - which is small because those operations are bound by per-pixel logic and by writing to a panel buffer that is in PSRAM either way.
The UI task's stack was 16 KB on a measured high-water mark of about 7 KB, and Wikipedia was the first page to want BuildDocumentL, netsvc_start and newlib's vfprintf live at the same time. It smashed the canary at 17.5 KB. The stack is 24 KB now, and Wikipedia leaves 7,016 bytes free at worst. A high-water mark only tells you about the pages you have actually visited.
How it is tested
The engine layers compile as plain C++ on macOS against a stub GDI, which is the largest schedule lever in the project. Most of the work happens in test/host with real corpora and a headless renderer that writes PNGs, not over a serial line. There are 33 host suites. Figures below were re-run today:
| Corpus | Result |
|---|---|
| html5lib tokenizer | 2737/2737 whole input, 2737/2737 one character at a time, 0 stream mismatches |
| html5lib tree construction | 964/1026 (94.0%) |
| css-parsing-tests, component values | 38/41 |
| css-parsing-tests, colours | 241/241 |
| css-parsing-tests, declaration lists | 10/10 |
| test262, ES5.1 | 11,255/11,255 parsed, 6,518/11,255 run clean |
| Bring-up pages | 26/26 |
The two tokenizer numbers being equal is the part that matters. Output no longer depends on where the input happens to be split, which on the device is wherever packet boundaries land. Getting there meant replacing peek-ahead in two states with accumulation; peeking worked on whole documents and stalled completely at one character at a time, which was 966 cases.
The 94% on tree construction is the honest figure and the remaining 6% is mostly not worth having: <isindex> expansion, <svg> inside MathML <annotation-xml>, and the adoption agency algorithm interacting with tables. Two of the biggest single jumps in that score came from fixing the harness rather than the parser - it emitted attributes in source order where html5lib sorts them, and capped attribute sorting at 32 where one test file puts 62 on a single element. A test harness gets the same scrutiny as the code, or the score measures the harness.
The bring-up suite is 26 self-contained pages, one per subsystem, each of which prints PASS or says what failed. On its first run it found three real faults, including the fact that nothing on the device was draining the microtask queue
- so every
.then()would have been queued and never run. It was invisible on the host because the test harness did the draining itself.
How it connects to the other projects
It shares the UI toolkit with diy, the Psion Series 5 reimplementation, vendored as a git submodule at vendor/diy: descriptors and leaves, the 4bpp graphics layer, the control framework and the EIKON widgets. The browser takes e32, gdi, coe, eikon and board, and not opl, store or sqlite - the SQLite amalgamation alone is most of a megabyte of the flash budget. It adds everything above the toolkit: networking, the HTML5 parser, CSS, layout, paint and the JavaScript engine. A toolkit fix made while working on the browser is the same fix the Psion gets.
The sharing goes further than source. vendor/diy/tools/screenshot.py drives this firmware with no changes at all, because src/debugcon.cpp keeps diy's serial wire protocol - so screenshots, key injection and taps all work across both projects with one tool. Every image in this article came out of it.
The cost is visible too. Catching up with 104 commits of the shared toolkit took the firmware from 1,580,143 bytes of flash to 1,978,003, and most of that was not the toolkit getting better: eikon acquired a dependency on audio because CEikonEnv ticks an alarm server, audio indexes every sound it has so the linker cannot drop any of them, and the browser now carries twelve 22,050-byte alarm samples, about 265 KB, for a program that will never play a note. That is being fixed where it belongs, by moving the samples to the SD card, which removes the cost for every application rather than just this one.
It is earmarked as the browser for Path OS, the from-scratch operating system project, where it is cloned into upstream/web as the engine to bring across. It is the last stage of that roadmap, and scheduled last on purpose: Path OS publishes its UI as a semantic tree of real elements, and a browser that has to publish a page's links, headings and fields into that tree is the hardest test of the idea. Doing the hardest test first would have proved nothing about the easy cases.
Sharing the board with diy
The two projects use different partition tables. This one gives factory 3.25 MB and has no sysdrv; diy gives it 2.5 MB and adds one. Whoever flashes last wins, and the loser's application is left unbootable in a way that does not look like a partition problem at all:
E esp_image: invalid segment length 0xffffffff
E boot: Factory app partition is not bootable
The fix is a full pio run -t upload, which rewrites bootloader, table and application together. Recorded because the message names the wrong thing, and the partition table the bootloader prints just above it is where the answer actually is.
What is not built
- Only the first frame of a GIF is shown. An animation would drive a panel that costs a full repaint per update. Interlaced and transparent GIFs decode correctly; transparency composites against white, because nothing at decode time knows the page background.
- SVG line caps and joins are square. At the one to three pixels an icon stroke actually occupies, a round join does not survive the dither. Gradients, filters, text, clipping and
<use>are not drawn. - Table layout is simple. Columns get sized, and an infobox with narrow cells and long words looks like it.
- Brotli and zstd are not offered. gzip and deflate are, decoded streaming. The newer two want far more RAM for their windows and dictionaries than DEFLATE's 32 KB, for a saving that does not change the shape of the problem.
- A failed DNS lookup blocks for 14 seconds, which is lwIP's retry schedule, and for those 14 seconds the address bar just says "Connecting".
- WiFi credentials are set over the serial console only, and the on-screen keyboard cannot type
/or:, so it cannot enter a URL with a path. A serial hardware keyboard is planned; the console covers development until then. - Touch is largely unverified. Almost every tap so far has been injected over serial, which bypasses the digitiser entirely.
Where it started

That is the first milestone: the URL parsed, printed as a table, and a line underneath admitting that networking had not been written yet. The repository's first commit is dated 30 July 2026 and that screenshot is from the same day; the Wikipedia measurements above were taken on 3 August.
The application image built today is 2,152,704 bytes of the 3,407,872-byte factory partition, 63.2%, so on the 4 MB part there is roughly 1.2 MB left. Next in the queue are the origin boundary that review found, and then the ranked backlog from item 17 down: focus and keyboard behaviour, IntersectionObserver, ResizeObserver, the requestAnimationFrame lifecycle. A 16 MB board is on the list but flash is not the constraint yet.