I built Escourtly, a tool that lets you record an interactive demo of your product and share it as a single link. The person you send it to clicks through your actual UI without signing up for anything.
The browser extension is the part that records. I thought the interesting problem would be tracking clicks. It turned out the interesting problem was something else entirely, and I want to write down what I learned in case you ever need to snapshot a live page.
The real problem
When someone records a demo, I need to capture each step as a page they can later click through. The naive idea is "take a screenshot." Screenshots are dead. You cannot hover, you cannot scroll a dropdown, the text is not selectable, and it looks terrible on a screen that is a different size than the one it was recorded on.
So instead of a picture, I capture the DOM. At each step I serialize the entire live document into a single self-contained HTML string. Open that string anywhere and you get a frozen but real copy of the page: real text, real layout, real styles.
That sounds simple until you actually try it. A live page is held together by dozens of things that only work because the page is live: external stylesheets, scripts that inject content after load, images served from a CDN with cookies, relative URLs that resolve against the current origin. Snapshot it wrong and you get a pile of unstyled text and broken images.
What the serializer actually does
I started by writing the serializer by hand — XMLSerializer, manual CSS inlining, the works. The reasoning felt sound: the libraries I looked at needed a bundler step or added cold-start cost I did not want in a content script, and hand-rolling meant I understood every line, which matters when something breaks on a page you have never seen.
It fought me on every site. Each new page was a new way to be subtly wrong, and each fix held right up until the next page. Eventually I made two changes that ended the whack-a-mole, and both are worth stealing.
Change one: stop hand-rolling the DOM walk. Use rrweb-snapshot. This is the serializer under PostHog and Sentry session replay — a DOM walker hardened against millions of real pages. It already handles the things my cloneNode quietly dropped: CSS-in-JS that injects rules into empty <style> tags, adoptedStyleSheets, live input state, shadow DOM. I use it to capture the tree and rebuild it into a detached document, then serialize that to HTML. Handing this problem to code that has already seen every weird page on the internet was the single biggest quality jump.
Change two — and this is the one nobody tells you — cross-origin CSS is unreadable from the page, but not from the extension. If a stylesheet loads from a different origin without permissive CORS headers, page JavaScript cannot read its rules. The browser renders with them; your script can't inspect them. So the live page looks perfect and the snapshot comes out subtly wrong, because a chunk of the styling was invisible to your code. This is the fidelity bug for this kind of tool.
Here is the trick: a Chrome extension has a background service worker, and with host_permissions: ["<all_urls>"] its fetch() is not bound by the page's CORS rules — and it carries the user's cookies. So the content script can't read that CDN stylesheet, but it can ask the background worker to fetch it and hand back the text. I route every cross-origin stylesheet, font, and image through that proxy and inline the bytes. The snapshot becomes genuinely self-contained.
Worth saying plainly: this is something PostHog's replayer does not do — it loads the original CDN URLs live at replay time. Fetching and inlining them means the snapshot survives even if the origin later changes, and it renders identically offline. The extension's background worker is a capability a pure web app simply does not have, and it turned out to be the whole ballgame.
The rest of the pipeline is unglamorous cleanup, in order: strip <script> and <noscript> (tracking-pixel markup inside <noscript> renders as visible text once scripting is off at playback); strip the resource-hint <link>s (preload/prefetch/dns-prefetch fire network requests purely from HTML parsing — the source of most of my mystery /assets/*.js 404s); inline every remaining stylesheet and @font-face through the proxy; add a <base href> as a safety net for anything left relative.
The bug that had been corrupting everything
One more, because it is the kind of thing that hides for months. I was serializing the final document with XMLSerializer. It turns out XML serialization entity-escapes the text inside <style> tags — > becomes >, & becomes & — and the HTML parser does not decode those entities when it re-reads a <style> block. So any CSS rule containing a child combinator or an & arrived at playback as gibberish and was silently dropped.
Guess what uses > and &? Tailwind. Its space-y, divide, and every nesting rule. So Tailwind sites came out half-styled, seemingly at random, and I had been blaming the CSS inlining. The actual fix was one line: serialize with outerHTML (plain HTML serialization) instead of XMLSerializer, which emits <style> contents verbatim. Every snapshot I had ever taken on a Tailwind site had been quietly corrupted by my own serializer.
That is the whole game with this kind of tool. Your test fixtures always pass. The web is bigger than your test fixtures — and sometimes the bug is in the one line you never thought to question.
One product decision worth mentioning
I made a deliberate call that all recording control lives in the extension popup. No floating toolbar injected into the page you are recording.
It is tempting to inject an in-page widget, it feels more visible. But the moment you put your own DOM into the page you are trying to snapshot, you are now capturing your own toolbar in the recording, fighting the host site's CSS, and risking breaking the very layout you are trying to preserve. Keeping the UI in the popup means the page under recording stays exactly as its owner built it. The recorder is a guest that leaves no trace.
If you take one thing away
Capturing a webpage is not a screenshot problem, it is a "what does this page secretly depend on" problem. Scripts, preload hints, cross-origin styles, cookie-gated images, relative URLs. Each one is a quiet assumption that the page is still live and still where it started. A good snapshot is really just a long list of those assumptions, found and defused one at a time — and the two moves that mattered most were using a library that had already found most of them (rrweb-snapshot), and using the one capability an extension has that a web page does not (a CORS-free background fetch).
If you want to see the output rather than read about it, the recorder ships as the Escourtly Chrome extension and every demo you have ever clicked through on the site is one of these snapshots playing back.
Happy to answer questions about any of the specific steps in the comments.
Top comments (0)