Skip to content

Devtools

Devtools

An in-page x-ray for the island graph: see each region boxed on the page, its real JS cost, what crossed the wire, and when each island woke.

On this page

The devtools are a dock that mounts on each csr = false page, and x-rays the island graph: each region boxed on the page, its real over-the-wire JS, the values that crossed the boundary, and a timeline of when each island woke. It reads the same event bus that the runtime emits. Thus it shows what occurred, not a guess.

Enabling

One flag in the Vite plugin. It is compiled behind a gate. Thus, when off (the default), the full devtools graph (the dock, the instruments, their CSS) tree-shakes to nothing. You decide where it is on:

import { ogygia } from 'ogygia/vite';

export default defineConfig(({ mode }) => ({
  plugins: [
    ogygia({ devtools: mode !== 'production' }),   // dev server and preview builds, never production
  ],
}));

On the dev server, a launcher appears at the bottom of each csr = false page. In a build it stays hidden until you turn it on in your browser (see below).

What measuring costs the page

Devtools measures the page it shows, so it must not change what it measures. Loaded one after the other with measuring on and off, a heavy page (17,000 elements, 30 islands) takes about 15 ms more main-thread time and 1.6 KB more HTML (the server’s events), with no measurable change in server time. With the dock open, each tab does at most a few ms of its own work every 3 seconds, and never a long task. internal/bench/devtools-build-check.mjs checks the first two numbers on every run.

In a build (a preview deploy)

With the flag on in a build (a QA, UAT or preview deploy), a page carries only a small switch (about 10 KB of JS with the event recorder). Visitors see nothing: no launcher, no measuring, no server event data. The build says so once, loudly, so a flag left on by mistake is seen before it ships.

Turn devtools on in your own browser:

  • add ?og-devtools=on to any page’s URL (the address drops it once it is applied), or
  • run ogygia.devtools.on() in the browser console.

It stays on across navigations and reloads (an og_devtools cookie, for 30 days). From then on the launcher shows, your pages are measured from the start (the Page tab), and the server adds its event data to them (the Wire and Timeline tabs). The dock itself (its code, every tab, its CSS) loads when you open it.

Turn it off with ?og-devtools=off, ogygia.devtools.off(), or hide in the dock’s title bar. ogygia.devtools.status() says which it is. Every other visitor never sees any of it.

Two things stay dev-only: the Main thread section’s CPU sampling needs a Document-Policy: js-profiling response header (the dev server sends it; set it on your preview host to have it there), and a Kit-hydrated (csr = true) page gets no dock in a build.

The dock

The launcher peeks up from the bottom edge. Hover to show it. Click (or Alt+O) to open the panel. The panel drags by its title bar, and resizes from each edge. Its position, size, and open state persist across reloads. It lives in a shadow root. Thus nothing from the CSS of your page can reach it, and its own styles cannot leak out. trace copies an event trace to the clipboard. Where the browser refuses the clipboard (plain http off localhost, like a phone on your network), it saves the trace as a file instead.

Instruments

  • Lens: the boundary overlay. Set it on, and each <ogygia-region> on the page is boxed and labelled by name, tinted by kind (island / lake / hole), and dashed while cold. A roster lists them with kind, wake schedule, JS cost, and hydration state. Hover a row, and its box lights up on the page, and the reverse. “Pick on page” lets you click a region to inspect it.
  • Page: what the browser saw of this visit. See The Page tab.
  • Record: press record, use the page, press stop. See Recording a session.
  • Hydration: each island’s hydration status, and what differed. See The Hydration tab.
  • Bytes: the byte ledger. In a build, it reads every file each island needs from the page’s island graph, and weighs each file as the browser loaded it. Per island it shows the files and their size, only it (the bytes no other island needs: what removing that island would save), and shared. The page total counts each file once, and a list names the shared files and the islands that use each. The runtime’s files get their own row. An island that has not woken yet shows its files as cold. On the dev server (no island graph, unbundled modules), it shows each island’s entry chunk and an estimate of its whole bundle, where shared code counts once per island.
  • Wire: all that the server shipped across a boundary: the devalue props per island, the page/query seeds, capabilities.
  • Hub: the identity spine. Per Ref id: its kind, resolve count, reunion (memo-hit) count, and disposal.
  • Nav: per-region SPA-navigation decisions (keep / patch / mount / remove), with timing. Thus you see the reconciler at work.
  • Timeline: client-realm events on a time axis, one lane per region, with hydrate bars.
  • Profiler: runs the SSR profiler on this page, right in the dock (needs ogygia({ profiler: true })). It shows the score, the server render time and what it would be after the fixes, the findings, the heaviest components, and the outbound calls. Each island’s server row joins the island on the page: hover to light it up, click to open it. The island’s detail card then shows the same numbers under “on the server”. The full report is one click away.

The Page tab

The Page tab reads the same browser data the profiler’s beacon sends, but live, on your own page. Nothing leaves the page. It shows:

  • Vitals: TTFB, FCP, LCP, CLS and INP, rated good, needs work, or poor by the web-vitals lines. Long tasks too.
  • Findings: what the browser measured going wrong. Each one names its islands. Hover it, and the islands light up on the page. Click an island chip, and its detail card opens.
  • Island wakes: a waterfall, one row per island. Each row splits into three parts: the module load, the wait for its turn (islands hydrate one per task, viewport first), and the hydrate step itself. FCP, LCP, DCL and load are marked on the axis.
  • Hole answers: a waterfall, one row per hole, on the same time axis. Each bar is cut where the browser and the server timed it: before the request left, the wait for a render slot on the server, the server render, then the network and the swap. The time at the right is how long the fallback stood. A hole whose answer never came gets a red row with the reason. Hover a row to light the hole up on the page; click to open it.
  • Main thread: the browser’s own sampler (the JS Self-Profiling API, in Chromium) records the first 8 seconds of the page. It shows busy time by kind of code (your app, ogygia, Svelte, packages, a page script, the browser, devtools itself), and the functions that ran, with file and line. Each island row shows the CPU inside its hydrate window. “Record 5 s” samples again while you use the page (a click, a nav). The dev server sends the Document-Policy: js-profiling header this needs. The trace stays in the page.
  • Blocked the first paint: the files that held the first paint back.
  • Styles: every stylesheet on the page, linked or inline, with its rules, its size, whether it held the first paint, and how many of its rules match nothing on the page right now. Devtools’ own styles are left out. Content that appears later (a menu, a dialog) counts as unmatched until it is open, so open it and press check again.
  • Files by type: count and bytes for scripts, styles, images, fonts and fetches. Every file counts, not only the first ones: the browser’s resource list stops at 250 entries unless asked for more, and the page asks.
  • Since your last load: change the code, and the page reloads. The tab then lists what changed on this page since the load before: findings fixed and new (by island), islands whose hydrate step or file load got faster or slower, and vitals that moved. A moved first byte, first paint, largest paint or slowest interaction names the part that moved most, in the same parts its explanation uses: “LCP 2.9 s → 500 ms, mostly its download 2.7 s → 300 ms”. Small wobbles are left out, by the same rule the profiler’s “since your last profile” uses, so the two never disagree. Each page’s last load is kept in this browser tab only.
  • What this browser cannot measure: Safari reports no layout shifts, long tasks or CPU samples. A finding that needs one of them cannot appear there, so the tab says which ones are missing, rather than let their absence look clean.

The findings it can name:

FindingWhat the browser saw
failed to hydrateThe island threw while it loaded or mounted. It shows its server HTML and does nothing.
threw away the server HTMLSvelte discarded the server DOM and rendered again (a flash, and a double render).
markup changedThe island rendered different markup in the browser than on the server. The finding shows the first difference.
clicked before it wokeA visitor pressed the island before it was awake. The first click did nothing. (An island with wake: 'interaction' is exempt: its first click is its wake, and is replayed.)
hydrating moved the layoutA layout shift came from inside the island, just after it woke.
long hydrateThe hydrate step was one task of 50 ms or more. The page could not respond meanwhile. With a CPU trace, it names the function that took the time.
waited for its turnThe island had its code, but waited behind other islands. The finding names them.
slow moduleThe island waited a long time for its code.
eager below the foldA wake: 'load' or 'idle' island starts below the first screen. wake: 'visible' would load its code later.
woke lateAn island on the first screen woke a second or more after the page looked done (LCP).
largest paint repaintedThe LCP element sits in an island that changed its markup as it woke.
never wokeA wake: 'load' island is still asleep three seconds after load.
long tasksLong tasks ran outside any hydration: page scripts held the main thread. With a CPU trace, it names the script and line; without one, the browser’s long-animation-frame records name the script.
other originsScripts from other origins: their size, the main-thread time they took, the files they held the first paint with, and the scripts they loaded that the page never names (how a tag manager adds code). A well-known service gets its kind (tag manager, analytics, session replay, chat, A/B testing…) and advice for that kind.
another origin held the first paintA script or style from another origin blocked the first paint.
another origin may have edited an islandAn island did not wake from the server’s markup (it changed, was healed, recovered or failed), and scripts from another origin ran before it woke.
Svelte warned while hydratingOn the dev server: Svelte’s own hydration warning (the server and the browser disagreed, and Svelte kept the server’s value, so the screen does not show it). With the island that was hydrating, when that is known.
empty children slotAn awake island shows its children in a slot with nothing in it. An island’s children are the server’s HTML: children behind a condition that was false on the server cannot appear in the browser.
styles shipped without their scopeogygia builds the CSS for lakes, holes and router pages itself. When it cannot compile a component’s scoped styles, it ships the raw style body, whose rules then apply to the whole page: a .row in it restyles every .row. The build marks such CSS, and the finding names the component and the reason. The build log has the full message.
held with nothing aheadAn island had its code but waited for its turn while no other island was hydrating and nothing on the first screen was still loading, past the start gate. Nothing on the page explains it: ogygia’s scheduler held the island. It is ogygia’s cost, not your page’s; the finding asks you to report it.
a barrel imported wholeOn the dev server: an island’s module graph still holds a re-export barrel (little code of its own, many modules behind it), so everything it re-exports rides into the island. The finding names the barrel and how many modules come with it. With ogygia({ barrels }) on, the build log says why that one was left.
one module is most of an islandOn the dev server: one module is 40% or more of an island’s code, 30 KB or more of it (dev code, unminified: shares, not shipped bytes). The island card lists its heaviest modules too.
a hole answered lateA hole on the first screen showed its fallback for a second or more before its answer came. The time runs from the first paint to the swap. A hole woken by a scroll is left out. The finding splits the wait: time before its request left, time waiting for a render slot on the server, and the server render. The fix follows the biggest part. A server process renders 4 holes at a time, and a hole keeps its slot while it awaits its data, so slow holes make the next ones wait. The split comes from the answer’s Server-Timing (og-queue, og-render), which the server sends only to a browser that measures: the dev server, the devtools cookie, or the profiler’s.
an island’s file goneAn island could not load its own content-hashed file, so it loaded its stable name fresh. The page came from a cache that outlived the build that made it. Its HTML and that island’s code now come from different builds. The finding names each island and says whether it woke. Keep the previous build’s _app/immutable/ files as long as your HTML stays cached, or purge the cached HTML on deploy. See Island files after a deploy.
island files not cachedIn a build: the host serves the islands’ files or the runtime without a long cache (no-cache, no header, or a max-age under a day). Their names change with their content, so a returning visitor should never fetch them again. The Page tab asks the server once per file (a HEAD request) and names the heaviest files first, with the header it saw and the bytes a return visit pays. Serve _app/immutable/ with cache-control: public, max-age=31536000, immutable. SvelteKit’s adapters do, but a proxy, a CDN rule or a custom server in front can override it.
the first paintWhen FCP is over 1.8 s, the finding splits it into four parts. Those are the wait for the HTML’s first byte, the HTML’s own download, the files that blocked the paint (the slowest named), and the rest before the paint (the long tasks in it, when a script held the thread). The fix is the costliest part’s. It replaces the bare FCP line; the render-blocking finding still lists every blocking file.
the document held openThe page painted early, but its HTML kept coming for 250 ms or more, and the islands woke only after its end. Islands wake once the whole document is in, so every island waited, even one that reads nothing late. When a load’s streamed promise held it, the finding names the page.data key that settled last. The fix: await it in the load, or make the part that needs it a server island (render: 'deferred').
the first byteWhen TTFB is over 800 ms: each step the browser timed before the page’s first byte, the ones that took time. Those are redirects, a service worker starting, the DNS lookup, connecting (and its TLS), and the request until the server answered. When the page sends a Server-Timing header, the finding quotes what the server says its time went to; the profiler adds its own render and CPU entries there for its user. The fix is the costliest step’s. It replaces the bare TTFB line. When the server’s wait is the cost, the Page tab adds what the Profiler tab’s last run of this page found: its render time, its outbound calls, and its own word on where the time went. With no run yet, it says to run one. A slow navigation’s fetch does the same for the page it went to. When the document’s Server-Timing carries the render (the profiler adds it for its user), the wait is split into the render and what came before it. On the dev server, most of that is the page compiling on its first request, so the finding says so and becomes a note: reload for the real first byte.
the worst shiftsWhen CLS is over 0.1: the worst burst of shifts (shifts under a second apart, five seconds at most, the way CLS scores them), what moved in it (the island, else the element), and what made it move. The cause is the thing that happened just before its biggest shift: a hole’s answer swapped in (not the size of its fallback), an image arrived with no size set, a web font swapped in, or an island hydrated. The fix is the cause’s, and when nothing the page timed explains it, the finding says what to look for. It replaces the bare CLS line.
the largest paintWhen LCP is over 2.5 s: which element it was, which island it is in, and its four parts. They are the wait for the HTML’s first byte, the delay before the browser began fetching its file (found late: a CSS background, a script-added image, loading="lazy"), the download, and the delay after it arrived before it painted. A text paint has only the first byte and the render. The fix is for the part that cost most. It replaces the bare LCP line.
a slow in-app navigationA router navigation took 400 ms or more before the new page showed (a warning past a second). The router times each one in three parts: fetching the page from the server, loading its stylesheets (it waits for them so the page never shows unstyled), and swapping it in. The fetch is split again by the page request’s own timing: the wait for the server’s first byte, and the download with its size. A page whose download is most of it gets the “send less HTML” fix instead of the server one. A link the router prefetched on hover says how much earlier its request began, and how long the visitor still waited after the click. It then counts the islands the new page woke, and names the heaviest. The fix follows the part that cost most. After the navigation, the Page tab shows the one that brought you here; the profiler report lists the slowest in the visit.
the slowest interactionWhen INP is over 200 ms: which interaction it was (a click or a key press, and on what), which island it landed in, and where its time went. The browser splits it into three parts: the wait before its handlers could run, the handlers, and the paint after. The finding names what held each part. For the wait, that is an island that was hydrating, or a script in a long frame (its file, its function, and whether a timer, a promise or an animation frame ran it). For the handlers, it is the island’s own handler; Svelte sends every event through one dispatcher, so that is never named. The fix follows the part that cost most. It replaces the bare INP line. When the browser can sample the main thread, the finding names the function that ran instead: the handler’s own function, or the one that held the thread during the wait. A click during the load comes from the load’s trace. A later one comes from a second sampler, which waits up to a minute after the load for an interaction of 200 ms or more and keeps only that one. A line is quoted only when the frame went through a source map; otherwise you get the function and its file. A built chunk’s minified name is never quoted.
files fetched againThe browser asked the server again for content-named files it already had. Either each was revalidated (a round trip answered 304), or on a reload it came down whole. The island-files row above reads the header the host sends; this one is what the browser did about it on this visit. The profiler report raises it from the same visit.
a preload downloaded againA file was fetched by a <link rel="preload"> and then downloaded again by the page: the preload did not match the request, so the browser could not use it. The finding names each file and the bytes paid twice, and the fix for its kind: a font preload needs crossorigin; a fetch preload needs crossorigin matching the request’s credentials; a module script wants modulepreload.
rules that match nothingHalf or more of the page’s CSS rules, 20 KB or more of them, match nothing on the page. The finding names the sheets that hold them.

On the dev server, modules load one by one. Thus load times run higher than in a build. The other findings hold in both.

On a Kit-hydrated page (csr = true) ogygia’s runtime never boots, so there are no islands, no wire and no byte ledger. The dock says so, and still shows the Page tab: the vitals, the findings about the page, the files, third parties and styles, from the beacon its boot starts.

After an in-app navigation (ogygia’s router swaps the page without a reload), the tab reports on the new page: its findings and island rows count from the navigation. An island on both pages with the same props is kept by the router, still awake, with no code to load and nothing to hydrate. The tab lists those as kept, so a page whose islands were all reused does not read as “nothing woke”. The Styles section reads the new page’s sheets. The vitals stay the first page’s load, labelled as such, since a navigation paints nothing the browser reports. The load CPU trace is the first page’s too; record the main thread again for this one.

Checked against planted problems

The playground’s /dt-lab page plants one problem per island, next to healthy decoys. internal/bench/devtools-answer-key.mjs loads it like a hurried visitor, and checks that each problem is found on its island and that no decoy is named. /dt-third plants other origins (a blocking script that edits an island, a script that loads three more). /dt-styles plants an unscoped component and a sheet of 600 rules for markup the page does not have. On /dt-many (320 islands, scrolled) the key checks the runtime itself: no island may wait with nothing ahead of it. internal/bench/devtools-click-check.mjs presses every button, link, row and chip in every tab on each lab page: each press must change something, throw nothing, and stay on the page. internal/bench/profiler-click-check.mjs does the same on a profiler report, the dashboard and the compare page. The same key checks the profiler’s report of the same visits. internal/bench/all-keys.mjs runs every key, each against the server it needs.

Recording a session

The Record tab measures what you do, not only the page load. Press Record, use the page (click, type, scroll, navigate), press Stop. The tab then lists what that stretch did. Each finding points at its elements: hover lights them up on the page, and “show on the page” scrolls there.

A session survives a full page load (a plain link, a form post, a reload): the page saves its part as it goes away, and the next page’s devtools picks the session up. The report covers every page on one clock, with each page load marked on the timeline. Islands keep the names they had on their own page. The browser’s CPU samples stop at a full page load (the sampler cannot be carried over); everything else carries on.

With the profiler on, and the server (on by default) records the server over the same stretch. “On the server, meanwhile” then lists the requests your actions made, the server’s hottest code across them, its outbound calls, and its findings. A slow request also gets profile it on the server: one click profiles that exact request and shows where its time went. The session’s timeline puts everything on one clock, and each request bar shows its server part and, inside that, the upstream wait.

FindingWhat was measured
slow interactionA click or key press took 100 ms or more to show a result. It splits the time into waiting, the handlers, and painting, and names the function that ran (from the CPU sampler), or the script (from the browser’s long animation frames).
repeated clicksThe same element was clicked 3 or more times within a second: to a visitor, nothing seemed to happen.
dead clickA click changed nothing: no DOM change it could cause, no request, no navigation.
late answerA click’s island changed only after half a second: show a reaction at once.
long tasksMain-thread tasks of 50 ms or more, and the function behind them.
layout moved by itselfA layout shift no input caused, and the island it came from.
requestsFailed ones (status 400 and up), slow ones (a second or more), and the same request sent again and again.
islandsIslands that woke during the session, and any that failed, threw away their server HTML, or were healed.
errorsUncaught errors, unhandled rejections, and console.error calls.
DOM churnAn island (or code outside islands) that changed the DOM hundreds of times.
heap growthThe JS heap grew by 5 MB or more.
slow navigationAn in-app navigation took half a second or more.

Checked against planted problems

The playground’s /dt-session page plants one problem per island (a slow handler, a late answer, a late banner, repeated and failed requests, an error, DOM churn), with a dead button and a healthy decoy. internal/bench/devtools-session-key.mjs records a session there like a visitor would, and checks that each problem is found and named, and that the decoy is never named.

The Hydration tab

Each island on the page, with its status:

  • failed: its module threw while it loaded or mounted, with the error.
  • recovered: Svelte threw the server DOM away and rendered again, with the runtime’s reason.
  • healed: another script edited it before it woke; the runtime put the server markup back.
  • markup changed: the browser’s first render differs from the server’s. Click the row for the difference: the server’s markup against the browser’s, with only the changed pieces and a little context around them.
  • clean, or asleep (not woken yet).

Hover a row, and the island lights up on the page. Click a row with a difference to see it; click any other row to open the island’s card.

A script that edits an island before ogygia’s runtime has started shows as markup changed, not healed: the runtime’s copy of the server markup already carries the edit. The difference then shows the edit.

An island inside another awake island does not wake on its own: it rides the hydration of the island around it. It shows as clean (with “rides …, the island around it”) once that island is awake, never as asleep.

The profile on the page

After a run, the Profiler tab lists On this page: the costs it measured that have a place on the page. These are the heaviest islands, the components (by the elements they rendered, on the dev server), a span split by element tag, the declarative shadow roots the server wrote (and how many the browser refused), and the largest paint. Hover one, and its elements light up. Click it to pin the highlight and scroll there.

Inspecting one island

Click a roster row, or use “pick on page” and click a region. Its detail card opens: identity, the full cost breakdown, its decoded props (as a collapsible tree, with hub-ref chips), and a client-lifecycle mini-timeline (connected → scheduled → woke → hydrated). Under “in the browser”, it shows the island’s row from the Page tab (module load, wait, hydrate step, layout shift), and each finding that names it. In a build, “cost” shows its code: the files it needs and their size, only it (the bytes no other island needs, which is what removing it would save), and what it shares, with which islands.

Keyboard and screen readers

The dock is a heading with a tab list: the tabs are announced as tabs, with the open one selected, and the panel below them is a region you can focus and scroll with the keyboard. Its text meets 4.5:1 contrast. Everything you can click, Tab reaches: a row’s name is a button, so Enter opens the row as a click does. internal/bench/devtools-a11y-check.mjs checks every tab with axe, measures the contrast of each text against the background under it, and fails on any control only a mouse can press.

TIP

The devtools never mount on profiler pages, the embedded frame of the Profiler tab included. Thus the tools never x-ray themselves.

`csr = true` pages

On a Kit-hydrated (csr = true) page, the ogygia runtime never boots. Thus there are no islands to inspect. The dock shows a notice that points you at a csr = false page.