Skip to content

Profiler

Reading a report

What each part of a profile report tells you, from the suggested fixes and the slow patterns found on your lines down to memory, the wall-clock budget, the CPU treemap, the network waterfall and comparing two reports line by line.

On this page

A report reads from top to bottom, starting with the question “is this render computing or waiting?” and ending at the exact function responsible.

The page has eight parts, ordered so that what you can act on comes first and the raw data comes last. What to fix holds the starting summary, the slow patterns, the exact lines, the findings, the budget bar and the paths. Why it took this long explains, slices and steps through one render, and shows the other requests that shared the instance. Memory covers who caused the GC, when the heap grew and what a render keeps. Then come hidden costs, the data (who reads what, the river, the seed, and the document as bytes), islands and the browser, and network and waits. Last are the raw numbers that everything above aggregates: the treemap, the tables and the flame graph. Nothing is left out, and the order on the page is the order in which you would read it.

Since your last profile

When this server (or the configured report store) holds an earlier profile of the same page, the report opens with what changed, for example “−68.6 ms since your last profile (163 → 94.4 ms), mostly: the Promise.all at +page.server.ts:24, −67.1 ms”. This is the comparison described further down, cut to three rows: the change in render time and the rows of where one render went that really moved. It says no real change when the render moved by less than the spread between its own runs. It warns instead when the order of the runs skews the result, which happens when the earlier profile kept 8 MB or more alive on every render and the later one therefore ran on a fuller heap. When patterns went away, it compares what they promised with how far the render actually moved.

When both profiles weighed the page, it adds how the JS at start moved and which files caused it (“JS at start 180 KB → 222 KB; new: the editor’s chunk 42 KB”). That covers files that are new, grown, shrunk or gone, as well as a file that moved between start and later loading, which usually means an island’s wake changed.

When both profiles had a browser visit, it lists the browser findings that went away and the new ones, along with the vitals that moved by more than the noise. The threshold is a fifth of the old value and at least 50 ms for the first byte, 100 ms for the paints, 40 ms for the slowest interaction, and 0.05 for CLS, which is the same rule the devtools Page tab uses for “since your last load”. For the first byte, the first paint, the largest paint and the slowest interaction, it names the part that moved most, using the same parts as the explanation: “LCP 3,000 → 500 ms, mostly its download 2,790 → 290 ms”.

It also tells you what the deploy costs a returning visitor. Each island’s file is named after its content, so an island either kept its file (and is still cached) or changed it (and is downloaded again): “Island files: 2 changed, 38 kept — a returning visitor downloads 14.2 KB again for Probe, Twin.” When every island changed, it asks whether their code really did, because by default SvelteKit bakes the build time into its own client code; set kit.version.name to your commit to avoid that. The agent JSON and the MCP digest carry the same information (since) at the top.

Start here

The first thing on the page is a list of the three biggest fixes, each with the line to open and what it is worth per render (“waits in a row, +page.server.ts:65, ~138 ms less waiting”). The line given is the one you should change. For a thin wrapper that is the caller that loops over it, and for a wait inside a helper it is the line that called the helper. A leak big enough to take the server down (8 MB or more kept per render) is listed first, whatever the timings say.

The fixes follow the forecast’s order (see After all of it), and each one says what a render takes once it and every fix above it are done: “waits in a row … → ~374 ms”, then “work for data nothing reads … → ~319 ms”, then “formatter per call … → ~269 ms”. You can decide where to stop by reading down the list. A fix that another fix already covers is not listed again, and the one that covers it says “also covers”. Work for data nothing reads often shows up in several rows (the helper’s time, the load line it was inlined onto, the garbage it made), but it is a single deletion, so it points at the load line that fills the key. The agent’s digest (ogygia_profile) gives the same plan with the same running totals.

The page score

The score is one number from 0 to 100 with a grade (A from 90, B from 75, C from 60, D from 40, otherwise F). It measures what a visitor pays for, whatever built the page, so a page with no islands that ships 6 MB of JS scores low rather than 100.

CategoryWhat it measuresWeight
JS at startEvery script the page runs at start: its <script>s, each modulepreload, what an inline script imports, each module’s static imports, a Kit-hydrated page’s route code, and the islands that wake at load. Islands that wake later count apart, as “on demand”.30
Server renderThe median render time.15
Hydration integrityIslands that threw away their server HTML, never woke, or changed their markup as they woke. Only on a page with islands, once the browser reported.15
LoadingLCP, FCP and TTFB from the visits.10
Data shippedThe page seed, island props, remote seed, and inline script text.10
Page weightEvery byte on the wire at start, the HTML included.10
ResponsivenessINP and total blocking time from the visits.10
Render-blockingStylesheets and sync scripts in <head>: their bytes, and a round trip for each.5
HTML documentThe size of the HTML.5
Visual stabilityCLS from the visits.5

Each category maps its value onto the same smooth log-normal curve that Lighthouse uses, defined by one point where the category scores 90 and one where it scores 50. Every byte and millisecond therefore moves the number, and nothing falls off a cliff. Each bar shows the points the category cost and its biggest contributors (for JS, the largest files).

A category with no measurement is left out rather than given a free 100, and the card says which categories are missing and why. Two cases matter in practice:

  • On the dev server the page is not weighed, because Vite serves modules one by one and the JS says nothing about a build. Profile a build (a preview or a deploy) to get JS, page weight and render-blocking.
  • Before any browser visit, loading, stability and responsiveness are left out. Open the page with the profiler on and the visit fills them in. This works on every page, with or without islands: on a page that never boots the ogygia runtime (a Kit-hydrated csr = true page, or a page with no island), the profiler adds a small standalone beacon of its own.

JS at start carries the most weight either way. A visit runs on your own machine, where parsing and running the JS costs a fraction of what a visitor’s phone pays, so the weighting keeps a clean visit on a fast laptop from hiding a heavy bundle.

Each large file names what the build put in it (the app’s files and the packages) in the score’s detail, in the “What the page loads” panel, and in a js-at-start finding.

With a browser visit, JS at start also counts scripts the HTML never names but the browser ran before the load event:

  • Scripts loaded by other scripts, such as a tag manager adding its tags or a library from a CDN fetching its own parts. The visit lists every script the browser fetched, the ones the page does not name are counted, and the finding says what share of the JS they make up.
  • Islands that wake when visible and were on your first screen. On that screen they wake at load, so they count at start. An island you scrolled to later does not.

Checked against a twin

The playground’s /score-lab and /score-lab-kit carry the same article and the same code editor, but one ships the editor as a lazy island and the other hydrates it the usual way. internal/bench/profiler-score-key.ts checks that the second page’s editor code is counted and that it scores well below the first, both with and without a browser visit.

The agent JSON carries the score and the weighed files (assets: the totals, the 40 largest files, and what could not be weighed).

While it weighs the stylesheets, the profiler also reads the mark that ogygia’s build leaves on CSS it could not scope. When the build cannot compile the component styles of a lake, hole or router page, it ships the raw style body, and every rule in it then applies to the whole page. A css-unscoped warning names the component and the reason, and the devtools Page tab raises the same finding, including on the dev server.

Slow patterns

This section lists the known slow habits of server code, recognized on the lines that cost the most and backed by what was measured there. Each card describes what is happening, gives the numbers behind it, shows every place it happens (with the surrounding code and the costly line highlighted), and gives the fix with a small before and after. The same habit in several files appears as one card.

After all of it: the forecast

Above the cards, the forecast says what one render would take with every fix done (“about 514 ms → 163 ms”). It does not add the fixes up naively, because many of them are the same time counted twice:

  • Fixes on the same line count once. A formatter built per call is often also “a library per item” on that line, and one slow call is often both “waits in a row” and “one slow call holds a batch”. Such a group gives back its biggest saving, and the forecast names which fix covers which.
  • Separate fixes add up, for example two chains of waits that run one after the other, or a loop in one file and a parse in another.
  • Fixes inside a call that another fix moves count once. Moving attachBrands(valid) out of the request takes the slow search inside it along, so the search’s own fix adds nothing more. The call stacks show which work runs only inside a moved call.
  • Work for data nothing reads is deleted. Its time counts, and any slow pattern inside it is left out, because deleting the work beats speeding it up. Deleting one call from a chain that “waits in a row” also fixes counts once together with that fix, since once the calls run together, dropping one of them saves little.
  • Waiting is capped at what the render really waited.
  • Keeping answers is a second figure. “The same answer on every render” is only a fix where an answer may be a little stale. That is a decision about freshness, so the forecast shows it separately: “keeping the services’ answers too: about 51 ms”. A cached copy of the whole page is left out for the same reason.
  • That second figure is measured. After the timed renders, the profiler renders the page up to three more times with those same answers served from memory (only its own renders get them). The figure is that render with the other fixes taken off. It catches what the lines cannot show: each call’s request and response handling and, for a call to your own server, the work of answering it.
  • Capacity is a third line. A waiting fix shortens how long a visitor waits, but only a CPU fix lets one core serve more requests. The forecast therefore also says how much main-thread time a render spends now and after the CPU fixes, and what that means in renders per second per core (“about 4 → 32”). That counts your code, packages, Node and the collector’s pauses on the main thread, but not the collector’s helper threads, which run on other cores. On Lambda, which bills memory multiplied by the whole render including waits, it also gives the GB-seconds per million renders now and after. On the test pages this figure lands within a third of the fixed copies’ own figure, because a fix’s replacement code costs some CPU of its own on lines the original never had.

Each resource is capped by what the render used. All the savings on the clock together (the wait fixes and the deleted waits) take off at most the render’s waiting, and all the CPU savings together take off at most its CPU. If the fixes claim more than that, they overlap in a way the lines do not show, and the forecast says so.

A build without sourcemaps shows fewer lines and therefore fewer fixes. The forecast says so, and the real time after the fixes is likely lower than it predicts.

Pick the fixes you will make. Under the forecast is a checklist of its parts. Untick what you will not do, and the page recounts the render after the remaining fixes in the same way; a fix that another one covers comes along with it. This answers questions such as “is the waiting fix alone enough?” without a second profile.

The forecast is checked against real fixes. Three test pages (one mostly waiting, one mixed, one mostly CPU) each have a copy with every counted fix applied by hand, and the forecast for each original lands within a few percent of what the fixed copy measures: 33.4 ms forecast against 34.3 measured, 169 against 155, and 397 against 384. Both sides are measured on a freshly started server (see A heap earlier requests filled).

PatternWhat it means
formatter per callnew Intl.NumberFormat(…) (or a locale method given a locale) on every call; building a formatter is slow, using one is fast
waits in a rowcalls from one line, each started after the one before it ended (a batch already started together is not part of it); the saving is their sum minus the longest, cut to what really comes off the render when another call ran beside them for as long
same request againthe exact same read (method and URL) made more than once in one render
same answer every rendera GET that ran in every profiled render and got back the same bytes each time (the profiler fingerprints each response): data to cache across requests. One row per kind of request and the line that makes it (“product/:id, 16 URLs”). A request that carried a cookie or an authorization header is marked per user, because the recording rendered as one user: cache that one keyed by the session. The saving counts only what comes off the render: a call that overlapped one left uncached still sets the end
same answer but for a stampa GET that ran in every render and never answered the same bytes twice, yet the first and last answers differ in one small stretch: at most 2% of the answer (48 bytes or more is always allowed). Usually that stretch is a generation time or a request id ("generatedAt":"…"), and the report quotes it. The data did not change, so it can be cached like an unchanging answer, with the changing part filled in or left out. If that part is the page’s data (a live price), keep only it live. Like the same answer, it is a caching lever, shown apart from the code fixes
same work every requesta const line in a load whose value reads only the module (its imports, its top-level constants, pure globals, or other such lines), so every request computes the same result again (const valid = validateAll(PRODUCTS)). It is found by reading the source and priced by the line’s CPU plus the whole time of the functions it runs (a function also called from a per-request line is not counted). Compute it once at module level. A line that awaits or reads the load’s arguments, a let, a module new Map() or the clock never counts
renders per itema component rendered 20+ times in one render (V8’s count), costing 5 ms or more a render, traced to the {#each} that renders it (in its parent, or a few levels up past an island’s host), the page-data key that loop walks (directly, or through a script local made from it) and the load line that makes the list. Its time is count × cost: send fewer items (a page of them) or make each cheaper. No saving is claimed, since it depends on how many you cut
waits for a late islanda call whose every page-data key the page’s markup uses only as props of an island imported to wake late (visible, idle, interaction, not already render: 'deferred'): every request waits for it before the first byte, for markup seen later if at all. The saving is the stretch the call held the render alone. Move the call into the island and import it with { render: 'deferred', fetch: … }
one slow call in a batchcalls started together from one line (a Promise.all) where the slowest takes 3× the next slowest and 20 ms more: the batch and everything after it wait for that one. The saving is up to the gap between the two. Start it earlier, hand its promise to what needs it, or give it a timeout with a fallback
same page every requestevery profiled render returned the same document byte for byte, no load takes cookies, locals, the raw request or the whole event, and no call carried a cookie or an authorization header: a cache can answer instead of a render (Cache-Control: s-maxage at a CDN, or ogygia’s postrender). The saving is the whole render. A handle hook that personalises the page from cookies is invisible to this check
same page but for a few partsthe renders’ documents differ in at most 5 % of their pieces and at most 6 groups, and nothing read belongs to the visitor. The first render’s document is split at its tags and lined up against the last, and each difference is grouped by what it is: an island’s props (its region and props script change together), one attribute stamped per render on whatever tags carry it (data-track, a renderer’s ids), numbered comment markers, or text inside a tag. Make those stop changing and the page caches whole
library call per itema library called once per item from a loop, with the library’s own time (even the part it runs after an await) charged to your line
rescan per itema big string scanned and rebuilt once per item (html = html.replace(…) in a loop)
search per itemfind / filter / indexOf over a list, inside a loop over another
walks to find a keyObject.entries(table) walked to find one key
rebuilt each calla fixed table (a literal-sized Array.from, a literal new Map([…])) built inside a function that runs many times
copy per step[...acc, x] or { ...acc, k: v } in a loop or reduce
deep copyJSON.parse(JSON.stringify(x)), structuredClone
slow regexthe time is inside a regular expression itself: V8’s own code for the pattern (a RegExp: frame), charged to the line that runs it, or a pattern written on a slow line that can backtrack. “Can backtrack” means a repeated group with nothing fixed inside it, like (\w+\s?)*: the engine tries every way to split the text before it fails. A cheap pattern run thousands of times stays quiet, because no rewrite of it helps
sort per call, date parsing, regex per call, yields per item, blocking disk, string growththe rest of the list, each named on its line
cache never hitsa memo (M.get(k), an early return, M.set(k, …)) whose miss path ran about as often as the function: a helper only the miss path calls ran once per call. Measured from V8’s call counts, which survive inlining; the usual cause is a key that is a fresh object every call
kept after rendermemory still alive after a warm render: see below
ships all page dataan island that takes page.data whole: see below

A pattern is reported only where the profile actually paid for it: the line has its own CPU time or allocations, the code has the right shape, and the context agrees (the line sits in a loop, its function ran more than once, or a caller runs it from a loop). A pattern that would save under a millisecond is not reported.

Optimized code charges an inlined function’s time to the line that calls it, and a loop body’s time to the loop header. When such a line has no shape of its own, the recognizer reads one level in, into the called function or the loop’s block, and names the line it finds there. A whole function inlined into its caller can also land its time on a neighboring line, and V8’s profile does not say which one. To handle that, the render that counts calls (which already runs with inlining off) is sampled too, and in it every function keeps its own frame. When a line’s neighbor calls a function that has no frame in the main profile, the line’s time is split according to that render: the line keeps its own work, and each neighbor gets its inlined function’s share. The line list marks this as time moved from line N. The un-inlined render decides only the split and never the amount, because everything runs slower without inlining. Two limits remain: only the two lines above and the one below are searched, and a function name defined in more than one file is not moved. The code is read with a small tokenizer rather than regular expressions, and only for the lines the report names.

The card’s header says what fixing everything is worth against the median render: “about 326 ms off the 741 ms render (44%)“. CPU and waiting are both per render; the CPU numbers of a page profile add up every run, so they are divided by the number of runs.

The exact lines

This list shows your own lines that cost the most, one row each, with the code and every cost joined onto the line, per render: CPU (the line’s own plus the time inside the libraries it called), bytes allocated, the GC they caused, and bytes kept alive. A CPU profile never records where a library was called from, so the profiler finds the line by reading the calling function’s source for the library function’s name or its import alias. Memory is measured per function rather than per line, so a function’s memory is shown on its busiest line, and the row says so. A line inside a dependency is not listed, because its cost reaches the list through the line of yours that called it.

Memory kept after every render

After the runs, the recorder renders the page once more under a live-objects sampler and then forces a full collection. Whatever survives was made by that render and is still held after the warm runs filled any caches, which means it is growth that happens on every request. The card names the lines, charges a library’s kept memory to the line of yours that calls the library, and counts down: “the heap (4.1 GB, 1.1 GB used) is full after about 43 more requests”.

When it finds something and there is time, it renders up to six more times with a collection after each, because a leak climbs every time (“confirmed … about 71 MB a render, and kept climbing”) while a cache with a size limit stops. The card says which it is, per line. It tells them apart by reading the same live sampler halfway through and at the end: a leaking line doubles between the two reads, while a cache that has filled stays put (the sampled objects are the same objects in both reads, so the noise of a single sample cancels out). A line in between gets no verdict rather than a wrong one, and so does a check of fewer than five renders. Calling a line a leak takes six renders, because on a tight time budget the check can stop at five, and over five renders a cache that keeps four entries holds four renders’ worth, which looks the same as a leak. A cache that holds more entries than the check had renders still reads as growth.

The sampler records where a kept object was made, not what holds it. So when something keeps a whole render’s data alive, a line that merely built part of that data (a menu tree, a formatted string) shows as kept too. The card lists only lines that hold at least 2% of the total and counts the rest in one sentence, since they go away when you fix the big lines. ogygia’s own code is never listed as your line, even when you run it from a linked workspace checkout.

On AWS Lambda (where Amplify runs SSR), the countdown uses the function’s memory size instead of the heap’s limit, because the platform kills the instance at that size: “the function’s memory (1 GB, 384 MB in use now) runs out after about 10 more requests on one instance”. That request fails, and the next one pays for a cold start. The same report also notes how much of the render was spent waiting on calls, because Lambda bills the whole duration, so cutting a wait saves as much as cutting CPU.

When most of the kept memory was made inside a library that your line calls, the card title says so (”… inside some-lib”). It names the library function that allocated the memory and suggests library fixes instead of the ones for a Map in your own code: create the library’s instance once and reuse it, call its cleanup method, turn off its internal cache, upgrade it, or, as a last resort, run it in a worker that you replace from time to time.

An island that ships all the page data

An island that takes page.data whole (through Object.keys(page.data), a spread, or by handing the object on) makes every key of the page’s data ship to the browser on every page view. The card points at the line in the island that does this and the bytes the seed ships. If the island reads the keys by name instead, seed shaping ships only those keys.

Where the time went

This is a single bar for the full window, split by what the CPU was doing, and it is worth reading first because it tells you which kind of problem you have.

  • When the largest segment is your code, you have a compute problem, and the treemap and the component table are the next places to look.
  • When the largest segment is idle / waiting, the server was blocked on I/O rather than computing, and a CPU profiler would have found nothing. Go to the network section instead.

This split between compute and waiting is the whole reason a plain CPU profiler misses “5 second” pages, because those pages are usually waiting.

Where one render went, down to the line

This shows one render’s time as a tree that adds up at every level. The top level is the part of the request (hooks, load functions, component render, and so on). Under the load functions comes each load file, then who held the time (your function, a library, or a call it waited on), and finally the line. Small rows fold into an N more row that you can open, so nothing is dropped, and a fold never hides more than a tenth of its level.

  • Your function splits by what its samples ran inside it: its own lines and the lines of the arrow functions and helpers under it, taken from the call stacks rather than guessed from names.
  • A library splits into the lines of yours that call it. When it is called from a single line, it splits into the library’s own functions instead (”@ionic/core → getNamedItem 23 ms, computeMode 14 ms”), which shows what kind of work it did for you.
  • A call you waited on splits into the line that made it and, when it went through a helper, the line of yours that called the helper. Waits that overlapped share the stretch evenly.
  • garbage collection splits into the lines that made the garbage, weighted by the pauses each caused.
  • nothing recorded names what was pending during the gap, with the line that queued it.

A row can carry these labels:

  • alone, or N ms alone, on a wait: how long nothing else was in flight. That is the least a faster answer would save, and always beside other calls means it saves nothing unless the other calls get faster too.
  • each call: X ms their side (…), Y ms network, Z ms body: the call’s own clock. “Their side” is the service’s own Server-Timing (nested entries are not counted twice), and the rest is the network, TLS, their framework or a queue.
  • → keys: the page-data keys that the row’s lines feed, read from your load’s source (a key’s expression, the variables it reads, the lines that set or fill them, and helpers in the same file). A key that nothing reads is struck through, and nothing reads it on a row means all of its time bought nothing.
  • N renders on a component: how many times it rendered during one render of the page.
  • a–b ms over renders: the row’s lowest and highest value across the profiled renders (for a wait, its calls’ own time). A difference smaller than that range is noise.
  • N ms cold: what the row took in the first, cold render, shown when it stands out. This is what a fresh serverless instance pays on its first request.
  • tags: the slow patterns found on the row’s lines.

Work for data nothing reads

The rows whose keys are all unread add up to one number: “newestReview 55 ms (CPU) → freshest, GET api/live 16 ms (waiting) → live_at, 71 ms of one render for nothing”. It joins start here when it is among the biggest fixes. A slow pattern inside that work (for example a date parse in a function that builds only an unread key) is left out there, because deleting the work beats speeding it up.

Why this render took N ms: the critical path

A page or request profile opens with one render walked from its first millisecond to its last. Every stretch of that window is one of three things. The CPU may be running something, coloured by the component or function that owned it. The server may be waiting on outbound calls or I/O primitives, drawn blue for HTTP and amber for timers, files and sockets, with several bars at once marking a parallel group. Or there may be a gap that nothing recorded, such as a database driver on its own socket pool, a worker, or a promise chain. The numbered steps under the bar are the critical path in order, and the biggest one is the one to fix.

Two more readings come from the same walk:

  • Phases split the render by where in the request the time was spent: hooks, load functions, component render, ogygia’s transform, and Kit’s routing and response. A wait is charged to the phase whose code was running immediately before it, so “load: 2 ms CPU + 210 ms waiting” tells you straight away that the page is data-bound rather than render-bound. A handle you import into hooks.server.ts from another file (sequence(myTransform, …)) counts as hooks too, including the work it does after await resolve(). The profiler reads your hooks file from the sourcemap, so this works on a deployed host as well. Library work that carries on by itself after an await, with none of your code under it, is charged to whichever phase last called into that library, which keeps other small.
  • Awaits in a row: two or more calls that ran one after another with nothing between them are flagged with what starting them together would save (the sum minus the longest). The profiler cannot know whether they are independent, but you can.

Who waited for whom: the causality graph

Under the timeline, Awaits draws the render’s outbound calls and spans as boxes on lanes, with an arrow from each call to the one that started right after it ended (within 5 ms, with nothing else in flight). A chain of arrows is a chain of awaits: the second call could not start before the first answered, either because it needs the answer or because the code says await where it could say Promise.all. Each arrow carries the gap between the two calls and, with source maps on, the await site that started the second call. The list under the drawing is the same graph as text, with the longest chain first. Many arrows on one lane is what a serial page looks like, and the same calls spread over several lanes with no arrows is what a parallel one looks like.

Gaps named. A stretch of the timeline that reads “nothing recorded” says what was pending during it, if anything was: nothing recorded — pending: GET /api/x (+2). The profiler cannot see the CPU work of those milliseconds (a driver on its own socket pool, a worker thread, a promise chain), but it can see which calls were still open, and that is usually the answer.

Slice it: the samples themselves

Every table in a report aggregates the whole render. Slice it, under the timeline, shows the same data before aggregation: every CPU sample of the render the report explains, each with its stack, drawn as a density strip (grey for any CPU, green for a component’s). Drag a range on the strip and the tables under it answer for that stretch alone, showing what ran itself (self), what was on the stack over it (owners), and which components were involved. Hover over an instant to list the stack at that moment, outermost frame first. The yellow marks are the collections. This matters because a hot function that is 12% of the render may be 90% of the 80 ms stretch you care about, and the stack at that instant says who called it there.

The index is limited to the window and folded (consecutive samples of one leaf become one entry), so it costs a few hundred kilobytes in the JSON rather than the megabytes of the raw profile. Past twenty thousand entries it is stride-sampled with the lengths kept, so a range’s CPU time still adds up. In the JSON it is stacks: a frame table with parent links and three parallel arrays (t_ms, d_ms, leaf).

Step through it: the render as a sequence

This presents the same render as a list you can walk through: this component ran for this long, then the server waited for that call, then the next component ran. Each step carries the time into the render at its end, the CPU used so far, the leaf that burned time inside it, the calls in flight during a wait, the span it sat in, and the stack at its midpoint from the index above. You step with a slider or the arrow keys, and the longest steps are jump targets. Consecutive segments with the same owner merge, and when there are more than eighty steps the smallest fold into a neighbour, which says how many it absorbed. In the JSON it is steps.

The instance was not alone

A Node process has one event loop, so every other request the server answered while the profiled render ran took turns on the same CPU. Those turns sit inside the render’s wall time without a frame of their own, which means the CPU tables do not include them but the wall time does. This section names the requests that overlapped the profiled window, how long each was in flight during it, and an upper bound on the CPU each could have taken. The bound is deliberately an upper one: a request’s CPU is a process-wide delta over its lifetime, so two overlapping requests each carry some of the other’s, and the estimate is capped at the overlap itself. The page’s own deferred holes appear too, marked as such, because they are the page’s own work answered while it rendered. A finding (busy-instance) fires when something else was in flight for a tenth of the window or more, and suggests reading the CPU numbers rather than the wall time, or recording on a quiet instance. In the JSON it is contention.

The CPU sampler hears the whole process, so another page’s work lands in the same profile. A sample whose stack runs under another route’s page or layout file (src/routes/purgatory/+page.server.ts, or its built chunk) belongs to that other request. It is set aside together with everything below it, so the other page’s slow lines, islands and seed never read as this page’s, and it counts as time the page did not run, like idle time. What stays as the page’s work is its own route’s files, the layouts above it, the endpoints it calls on its own server, and shared code sampled with no route file above it. How much was set aside appears in the finding and in the JSON (other_requests_ms).

Other visitors to the same page run the same lines, so no stack can tell their samples apart, but the clock can. A render that an outside request for the same path overlapped is left out, and its samples are set aside by time, as long as at least one render ran clean. When fewer than two ran clean, the recording makes a few more renders to find some, up to twice the number of renders asked for and within the time budget, and the finding runs-set-aside says how many. When every render was overlapped, nothing is left out, and the finding says how much higher the CPU can read as a result (“up to 5× on the lines they share”). On Lambda neither case arises, because an instance serves one request at a time.

Name what the profiler cannot see: span and tag

Two kinds of wait never show up on their own: a database driver on its own socket pool, and a promise chain (a queue, a cache client, a worker). They appear as the grey “nothing recorded” stretches on the timeline. You can name them in your code:

import { span, tag } from '@ogygia/devtools/profiler';

export const load = async ({ params, locals }) => {
	tag('tenant', locals.tenant);
	const user = await span('db.user', () => db.users.find(params.id));
	const orders = await span('db.orders', () => db.orders.byUser(user.id), (rows) => ({ rows: rows.length }));
	return { user, orders };
};

span(name, fn, attrs?) runs fn and records it the way it records an outbound call: start and end on the profiler’s clock, the request it ran in, the caller’s file and line, and its parent span. A synchronous fn stays synchronous and a promise stays a promise, and a throw is recorded and then rethrown. attrs is either a record or, for values only known afterwards, a function of the result. The report reads the keys cache ('hit' or 'miss'), rows, bytes and key. For a stream or an event that you cannot wrap, span.start(name) returns a handle with set, end and fail.

The Spans table carries the same three numbers as the components table. total is the spans’ summed duration. self is that minus the child spans nested inside them, so a ds.pass of 249 ms with 178 ms of renders and 69 ms of splicing inside has 2 ms of its own. wall counts overlaps once, so forty-eight renders of 127 ms under a Promise.all add up to 6 s of total but 180 ms of wall, and wall is what the render paid. A ∥ marks spans that ran together.

A span that carries an attribute with a few distinct values gets a by table in the Spans table: ds.render by tag says what each element type cost (count, total, p50), and db.query by table says which table the time went to. The N+1 finding names the biggest value. A repeated span that ran together (in a Promise.all) is told apart from one that ran one after another: it shows its wall time next to the summed work, and the fix is to make each item cheaper rather than to batch.

Spans show up in several places. On the timeline, a gap inside the span reads “in db.user” and a call inside it names the span. The Spans table gives the count, time per render, p50, max, cache hits and misses, errors, and the caller. The awaits-in-a-row detector treats spans as calls. Four findings also read them: a span repeated once per item (the N+1 shape), a span that takes a fifth of the render, a cache that misses on every render, and a span that failed or never ended.

tag(key, value) stamps the request’s log entry. You can then split the dashboard’s slowest-routes table by any tag (“slow for everyone, or for tenant X”), and the request rows in a report show the tags.

Both functions do nothing on the client, in a universal load running in the browser, and on a server with no recording running. Nothing is recorded outside a recording and nothing is sampled, so either a span is recording or the profiler is idle.

Values, not only functions

A span that reports a number (span('db.query', fn, (rows) => ({ rows: rows.length })), bytes, tags) gets a row per attribute under the Spans table, showing the range across the recording (min, median, max and sum) and how the span’s time moved with the value. ms per unit is the slope of a straight line through the spans, and fit is how well that line holds, where 1 means the value alone sets the time. A steep line with a good fit says the cost depends on the size of the input, which a function’s name can never tell you: the same render is cheap on 3 items and slow on 3000. A finding (value-driven) names the first span whose time follows a number that closely. A value that barely moved across the spans (by less than 5% of its size) gets no fit, because three renders of one document do not make a slope. In the JSON it is span_values.

A renderer or a client you call from everywhere: instrument

Some functions, such as a design system’s renderToString, a database client’s query, or an SDK method, are called from so many places that wrapping each call is impractical. instrument wraps the function once, and every call becomes a span:

import { instrument } from '@ogygia/devtools/profiler';
import { renderToString as core } from '@acme/design-system/hydrate';

// an ESM import is read-only, so bind the wrapped function
const renderToString = instrument(core, 'ds.render', (r, tag) => ({ tag, bytes: r.html.length }));

// a method on an object is patched in place; the return value restores it
const restore = instrument(db, 'query', 'db.query', (rows, sql) => ({ rows: rows.length, key: sql.slice(0, 40) }));

The attrs function receives the result and the call’s arguments, so a span can carry the tag it rendered. A page that renders three hundred custom elements through one renderToString then shows up in the Spans table as three hundred ds.render spans with the per-tag split, and the N+1 finding points this out.

The browser’s side: mark

The server profile cannot see the visitor’s browser at all, where a design system’s CDN components hydrate, a widget boots and a font arrives. mark from @ogygia/devtools/profiler/client is the browser counterpart of span:

import { mark } from '@ogygia/devtools/profiler/client';

mark.event('ds.hydrate', 'components-ready');   // a web-components runtime's "all hydrated" event
await mark('widget.boot', () => widget.init(), { version: '3' });
const h = mark.start('search.first-results'); /* … */ h.end({ hits: 12 });

Once the beacon has seen a visit, the browser’s side tells the report two more things: an island that never reported hydrating while others did (a visible island on a page that cannot scroll, or a load island that throws on wake), and an island that discarded its server DOM and re-rendered. The second is a hydration mismatch, meaning something edited the HTML between the render and the wake, such as a post-SSR pass. Both are findings, and the Islands table marks the affected rows.

The marks travel on the same beacon as the islands’ hydration timings, so they cost visitors nothing: the profiler puts its beacon tag only into documents served to its own logged-in user, and without the tag mark runs the work and queues nothing. The report shows the marks for the page under Marked in the browser (p50, max, failures and attribute keys), next to the vitals and the server render, and the slowest one gets a finding.

Paths to fix: hot functions that share a caller

After you remove the one function that took two seconds, a dozen smaller ones usually remain, and often several of them sit on one call path. If walk, format and sortRows all run under buildTree, fixing buildTree once (calling it less, caching what it computes, or moving it out of the render) takes all three with it. The report finds these groups by crediting each hot function’s time to every one of your callers above it; a caller that covers two or more hot functions is a path. The deepest caller that covers the time wins, so the group names the most specific place to fix and never the route root.

Each group is drawn as a graph, with the caller to fix on the left, the chain of calls through the middle, and the hot functions highlighted at the end. The width of a link is the time that flowed through it, and clicking a node opens its row. The finding above the graph says what share of busy time the path holds.

A node named fn @ file:line is an anonymous function, such as an arrow function or a callback, which V8 cannot name. When the source is on the machine, the label also carries the line itself (fn @ ds-ssr.ts:42 · tags.map(async (tag) => {), so you can see which callback it is.

Whose wait is it: the upstream’s own Server-Timing

When an upstream answers with a Server-Timing header, the profiler reads it off the response and draws it inside the wait: “GET api/catalog waited 220 ms; their side says db 180 ms, render 20 ms”. The finding then says how much of the wait is theirs and how much is the network and their framework, and therefore who to take it to. Any service can send the header, and a SvelteKit upstream with the profiler’s serverTiming on already does.

The request drawer in the waterfall shows the same split along with the call path, which lists not only the function that made the call but also the functions that called it (fetchStock ← load), read off the stack the call already captured.

Cold start: the first render on its own

A page profile renders the page once without profiling before the measured runs. That first render is profiled separately as a coarse window, and the report shows what it cost beyond a warm render per file: module loading, compiling, and first-call caches. On a serverless host every cold instance pays exactly this on its first request, so a “few hundred ms slower TTFB on preview deploys” shows up as a table of files instead of a guess. Pass ?cold=0 to the /page endpoint to skip it.

The cold-start warning splits the extra time into CPU and waiting. When that first render made outbound calls, the waiting is their new connections (DNS, TLS, empty pools), and the advice is to reuse one client. When it made none, the waiting is not the network but the first loads of the modules and files the render reads, which CPU samples do not show, and the advice is to have fewer and smaller server modules on the page’s path.

On AWS Lambda (where Amplify runs SSR) the report also shows the instance’s own cold start. A Lambda instance starts for a request, so the time from its process starting to its first request is what every new instance pays before any render: Node’s startup, followed by loading your server bundle and its imports. The report gives that time, Node’s share of it, how old the instance was, and how many requests it had served before the recording. On a long-running server that time is mostly spent waiting for traffic, so it is not reported there.

Per run: a cache, not a slow component

In page mode the components table has a per run column with the component’s time in each render, shown as min · median · max. A component that takes 90 ms in the first render and 2 ms afterwards points to a cold cache or a lazy import, and the row says cold 1st, while one that jumps in a random run says varies. Both get a finding, and in neither case is the answer to make the component faster.

A heap earlier requests filled

A page that keeps memory alive on every render also slows down every render after it, because a bigger heap costs more to collect and to allocate in. The renders within one profile can look flat while the next profile on the same server reads far higher: one test page read 720 ms on a fresh server, then 1 020 ms, then 1 310 ms, with the same code. When a profile starts on a heap that holds six or more renders’ worth of what the page keeps, the report says so (heap-filled-before) and gives the heap’s size and how many renders’ worth it holds. Fix what is kept, and until then compare only numbers from freshly started servers.

Slower each run: the instance is aging

When every render takes longer than the one before, the report says so, with roughly how many ms each render adds and the first and last run. It needs 4 runs (a cold first run is left out), and most pairs of runs have to agree, so a single slow run does not trigger it. When the same report caught memory that a render leaves alive, the finding names that as the likely cause and links to the Memory kept after every render pattern. On a long-lived server the climb continues until the instance restarts, so the times in the report are one point along it. Fix what is kept and profile again, and the runs should come out flat.

The browser against the server

With the beacon on (which happens when you open the page while logged in), the report also shows what your browser measured for the page: TTFB, first paint, LCP, CLS and INP, next to the server’s median render. Two gaps matter, and each gets a finding. TTFB far above the render comes from whatever sits in front of the server (a cold instance, a proxy, the network), and LCP far above TTFB comes from what the browser does after the HTML arrives (assets, fonts, hydration). In both cases the answer is not in the SSR code.

For the report’s own visit the TTFB gap is split further, because the browser timed its redirects and connection and the request log says how long the server’s handler held that same request. The gap therefore reads either as connecting, before the handler took the request (a proxy, a CDN, a cold start), or as time inside the handler beyond the render (a hook that awaits something), with the fix for whichever is bigger.

What went wrong in the browser

The visit the beacon sends is read with the same analysis as the devtools Page tab, so the report and the tab name the same problems on the same islands. These findings start with “In the browser:“:

  • an island that failed to hydrate, with its error
  • markup that changed as an island woke
  • a click that reached an island before it was awake
  • a layout shift from an island right after it woke
  • one long hydrate step, or a wait behind other islands
  • a slow module
  • an eager island below the first screen
  • a first-screen island that woke long after the page looked done, or the largest paint repainting inside one
  • long tasks outside any hydration
  • a slow in-app navigation (the router’s page swap): how long the fetch, the stylesheets and the swap took, and the islands the new page woke after
  • the slowest interaction, when it was slow: the island it landed in, and whether the time went to waiting (behind a hydrating island or a named script), to its handlers, or to the paint. The report’s INP line comes from all visits, while this finding explains the report’s own visit.
  • a file that was preloaded and then downloaded again, because the preload did not match the request
  • an island whose own file was gone, so the page (served from a cache that outlived its build) ran it on the current build’s code
  • content-named files (under _app/immutable/) that the browser asked for again although it already had them. Either each one was revalidated (a round trip answered with 304) or, on a reload, it came down whole; in both cases the host did not let the browser keep them. Islands are named, and an island that came inside a hole’s answer is named by its hole. Downloads on a first visit never count.
  • a hole whose answer never came, and why (a redirect, a whole page, a failed request)
  • a first-screen hole that showed its fallback for a second or more. The report adds how much of that wait was the hole’s server render, finding the hole’s own requests in the server’s request log because the browser makes them after the recording ends. When the render is most of the wait, the fix is on the server. It also separates time spent waiting for a render slot (a server process renders 4 holes at a time) from time before the request left. When the server answered quickly, the request started late or was held up on its way.

A page with hundreds of islands sends a visit larger than 64 KB, which is more than a browser lets a page send while it closes. While the page is open, the beacon therefore sends the visit as an ordinary request, which has no size limit, and as the page closes it sends a slim copy without the file list and the island rows that the earlier messages already delivered.

Each report reads its own visit, which is the first one your browser sent after the report was made. If you profile a page, fix it and profile again, the second report’s browser findings come from the second visit rather than a mix of both.

The beacon sends where each island sat on the page, so “below the first screen” is measured on your screen. When the browser’s CPU trace arrived too, the profiler cuts it into each island’s hydrate window and the long tasks outside them. A slow hydrate then names the function that took the time (“mostly render_rows (Heavy.svelte:11)”), using the same source-mapped names as the browser CPU section. The report’s JSON lists each window under browser.cpu.by_island. In the devtools Profiler tab, hovering over one of these findings highlights its islands on the page.

Not every browser reports everything; Safari and Firefox, for example, have no layout-shift, long-task or long-animation-frame entries. When the visiting browser lacks one, a browser-limits note says which, and the findings and score parts that depend on it are missing rather than clean. Visit from a Chromium browser to measure them.

Third parties

Every origin other than the page’s own counts as a third party. The visit lists what each one served, and the report groups that by origin, with a kind read from the host name (tag manager, analytics, ads, session replay, chat, A/B testing, consent, fonts, library CDN). For each origin it shows the JS it served, how many of its scripts the page never names (a tag manager’s tags), whether it held up the first paint, and, with the browser CPU, how long its code ran on the main thread. Three findings come out of this:

  • third-party: the total for other origins, the heaviest three, and one fix per kind. For a tag manager, audit its tags, fire them after the page is interactive or on consent, or move to server-side tagging. For a chat widget, use a plain button that loads the widget on its first click. For A/B testing, render the variant on the server. Moving a script into a worker (Partytown) is suggested only for pure analytics beacons, which tend to work there.
  • third-party-blocking: another origin’s stylesheet or sync script held up the first paint.
  • third-party-edits: an island did not wake from the server’s markup, and a third-party script ran before it woke. A script that edits the DOM under an island is the usual cause.

The devtools Page tab shows the same table, produced by the same code.

The browser’s CPU: which island cost the main thread

When the browser supports JS Self-Profiling (Chromium), the beacon also records the first 8 seconds of the page’s main-thread CPU and sends it in. The report then gets a browser CPU section built the same way as the server’s: components by self time (each island’s component name, taken from the chunk it hydrated from), hot functions, and a flame graph. It answers a question the server profile cannot, namely which island’s hydration ate the main thread and whether that was your code or a dependency (the rows are categorised as svelte, dependency or app). The profiler sends the Document-Policy: js-profiling header only on responses that carry the beacon, so a visitor without the profiler cookie is never sampled. Turn it off with clientCpu: false.

Production client chunks are minified, so a browser frame reads as Y in chunks/DvQX.js until a source map says otherwise. With the profiler on, the build keeps the client chunks’ maps for you (see Sharper names with source maps), so the profiler finds each chunk’s map, recovers the real function name and source file, and files the frame where it belongs: create_effect in Svelte’s runtime, ProductCard in your own component. When the map carries no name at the start of the function, the name is read from the original source line itself. Scripts from another origin (a CDN’s web components, a tag manager) have no local map, so they keep their minified names and are filed under the package that the CDN path names.

With the keyboard

Everything on a report that takes a click can also be done with the keyboard. Tab reaches each table row, sort header, timeline block and seed row, and Enter or Space activates it. A focused timeline block shows the same detail a hover would, and a sort header tells a screen reader its sort order. The CPU treemap and the flame graph are pictures of data that the tables also list, and the flame graph’s search box zooms to the heaviest match when you press Enter. internal/bench/profiler-click-check.ts fails on any control that only a mouse can press.

Take the report with you: the standalone HTML

Download .html on a report writes a single file with everything inlined: the styles, the runtime, and every island chunk the page reaches, as an import map of data: URLs. It opens from disk or from an email attachment with the tables sortable and the flame graph and timeline interactive. It needs a built app (vite build and preview, or the deployed server), because on the dev server the page’s code is hundreds of unbundled modules; use the .ogp export there instead.

It also works on serverless hosts, where the instance that made the report is usually gone by the time you click. The button sends the report along with the request, either as the page’s own compressed and encrypted .ogp copy (which only this server’s key opens) or as this browser’s kept copy, gzipped. Whichever instance answers builds the file from that copy, rendering the report page inside the same request. It never sends the report as raw JSON, which would be several MB and exceed a host’s request size limit. The JSON link, Share link, Keep in this browser and Export .ogp work the same way. Export needs the server’s key to encrypt, so a report shown from this browser’s copy sends that copy and gets the file back. The one exception is the .cpuprofile link: the raw V8 profile is not part of the portable report, so only the instance that recorded it can serve it.

Findings that name the fix

Each finding says what to do and where. A warning about a repeated component (”HeavyRow rendered 800 times”) carries the fix (render fewer, or move it into a deferred hole), the file, and a show the row link that opens that component’s row with its call stacks. Findings for a hot function, an N+1 chain (the same endpoint hit once per item, folded to /products/:id), a serialization-heavy render and an oversized page seed work the same way.

A report over a window of requests (a test’s clicks, for example, rather than one page) opens with window-requests, which gives how many requests the window saw and the slowest three, each split into CPU and waiting, along with how much of the wait was outbound calls. For a slow request it tells you where to go next: if it was computing, profile that page on its own to see the lines, and if it was waiting, look at the network table.

Load functions and the parent() chain

Under the timeline bar, a page profile draws one lane for each Kit load function that ran (+layout.server.ts, +page.server.ts, a universal +page.ts), each spanning from its first moment on the CPU or waiting to its last, on the same clock as the render. The chip says server or universal, and await parent() when Kit’s parent() ran under that load.

Kit runs a page’s load and its layouts’ loads together. When the page lane starts only where the layout lane ends, they ran one after the other, and the report says so along with the most that overlapping them would save. The usual cause is an await parent() before the page’s own fetches; start the fetches first and await parent() afterwards, and the two will overlap.

A load that calls the site’s own API through Kit’s fetch never reaches the network, because Kit answers it in-process. The profiler wraps that fetch for the requests it records, so those calls show up like any other call, with their caller and their Server-Timing. A wait inside a span() also counts toward the lane.

A universal load with waits gets a note as well, because on a page with the client router that code runs again in the browser on every navigation and its data has to be serializable. If it only needs the server, make it a +page.server.ts.

Kit’s own costs that grow with the document are named too: the ETag hash of the whole HTML, and devalue.uneval of the load data for the client router. The second is zero on a csr = false page, where islands take their data from props and the seed.

ogygia’s own cost

A page profile shows what the handle added: the transform’s wall time, the islands and holes it emitted, and the bytes of every side channel (page seed, island props, remote seed, fn manifest, context bridge). A seed over 100 KB gets a finding, because it means some island reads $page whole and that much of page.data ships on every render.

Islands: the server, the build and the browser on one row

The Islands table has one row per island fingerprint (identical copies share a row, marked ×N) and joins three sources:

  • The server. SSR ms is the component’s render time per page render. props is what the sidecar ships and which lane it took: json parses on the fast lane, and devalue names the one leaf that forced the slower lane, such as a Date or a Map. seed refs are props that point into page.data instead of shipping the data twice.
  • The build. JS is the unique weight of the island’s module and its preloads (in a built app). interactivity is what the build found in the island’s components: event handlers, $state, $effect, bind: and use:. An island with none of these reads inert, meaning it loads JS for markup that never changes, and the advice is wake: 'none'.
  • The browser. hydrate ms is what your own browser reported. When you open the page while logged in to the profiler, the runtime posts each island’s wake-to-hydrated time (and the module-loading part of it) back to the profiler, keyed by fingerprint. Visitors never pay for this, because the tag that turns it on is only added to your own documents. A client-hydrate finding names the slowest component (with its copies) and splits its median wake into loading its modules, waiting its turn (islands hydrate one per task), and the hydrate step itself. When the wait is most of it, the finding names the island that hydrated during that wait (“most of it behind Heavy, 140 ms of hydrating”), and the advice fits the cause: a shorter step for the island holding the queue, fewer copies waking at once, or a smaller closure.

A row with advice carries a mark. Opening it shows the modules with their bytes, the seed keys the island references, the devalue culprit, and the one line of advice the numbers support. The modules are every JavaScript file the browser downloads before that island can run, meaning its own code (marked this island) and the shared files it imports. The bundler names shared files by hash, so under each one the report lists what the build packed into it (svelte runtime, src/lib/hell/data.ts, date-fns), read from the build’s own module list and sorted by the bundler’s rendered size, so a big component is named even inside a file of many small ones. When one module is 40% or more of an island’s own code (and at least 20 KB), an island-heavy-module note names it: “Icons.svelte is 89% of Toolbar’s own code (42 KB of 47 KB, before minifying)“. Svelte’s and ogygia’s runtimes, SvelteKit and devalue load once per page and never count as an island’s own code. The devtools raise the same note on the dev server.

An island-barrel warning names a re-export barrel that an island still ships whole, detected from the build’s module info as a module with little code of its own and six or more modules behind it: “Toolbar ships a barrel whole: src/lib/dtcode/index.ts, and the 8 modules behind it”. The bundler never treats a Svelte component as free of side effects, so everything the barrel re-exports ships. The barrel pass leaves alone a barrel that has a statement of its own, and the fix describes both ways out. The JSON also gives each island’s js_only_bytes, the bytes no other island on the page needs, which is what dropping or deferring that island would really save. The islands-js-heavy finding names the island with the most of them, and when most of every closure is shared, it says that removing any single island saves little. Several findings link to this table: an inert island that wakes, a component with dozens of copies each waking on its own, a page whose islands load hundreds of KB of JS, and the slowest hydration in the browser.

The seed, explained

This lists every top-level page.data key with its size and why it ships. A key ships because an island’s client code reads it (the build’s answer, from seed shaping), because an island’s props reference it (a seed reference), or whole because some island reads page.data without naming keys, in which case everything ships and the report names that island. Keys that seed shaping left out are shown greyed out, so you can see what was trimmed, and clicking a key shows who asks for it. When the seed itself left the JSON lane, its devalue culprit appears in the heading.

Holes

This lists each deferred hole the page rendered, named by its component and its props as written in the page (Recommendations {"forProduct":"P1"}, with the manifest id in small print). For each it gives the fetch schedule, whether the hole wakes afterwards, its maxAge and, when hole requests landed in the window, what the render cache did (hits, misses or uncached renders) and the average time. A hole with a maxAge whose cache never hits gets a finding: its cache key includes the props and the session seal, so per-visitor props make every key unique.

Compare two reports, line by line

The comparison lists which slow patterns a change fixed or introduced, and the exact lines before and after, per render. Lines are matched by their file and the code on them rather than by line number. An edit above a line changes its number but not its code, so it keeps its row, and a rewritten line reads as the old line fixed and a new line added. Small patterns and cheap lines that come and go between runs of the same code are not reported as fixed.

Every CPU, memory-cleanup, allocation, component, function and path number in a comparison is per render, so a 3-render report and a 10-render report of the same code read the same instead of three times apart.

Where one render’s time moved lists the rows of the two breakdowns that changed, keeping only the deepest row of each chain: when a call’s line carries most of its phase’s change, the line is listed rather than the phase above it. A row that has a low–high range over renders on both sides counts as moved only when the two ranges do not overlap. When the render itself took the same time (within the spread of its runs), a CPU row’s change is marked shifted, because V8 inlines differently from run to run and can charge the same time to another function; newestReview −55 ms next to load:96 +62 ms is one swap, not two changes. Waits keep their verdict. A row missing on one side next to an N more row is taken at the fold’s size (”≤ 4 ms”, “at least −55 ms”) and never called fixed.

When both reports ran on one server and the earlier one kept 8 MB or more alive on every render, the page warns that the run order changes the result, because the later report ran on a fuller heap. Restart the server between the two.

When patterns went away or shrank, the comparison checks whether the fix paid off. It takes the older report’s forecast, counts the parts that got done, and adds them up the way the forecast does (fixes on the same lines once, each resource capped by what the render used), then compares that with how much faster the median render actually got, for example “expected to save about 360 ms per render, and the median render got 364 ms faster”.

A part counts as done when its fix, or a fix it covers, went away. A part that shrank counts by its share, judged by its own lines: if every line the older report flagged is gone, it is done, even if the newer report flags the same kind of pattern on other lines. Lines are matched by their code, so an edit above them does not confuse the check. Deleted work counts as done when the newer report no longer has it.

When the render gained much less than expected, the time most likely moved somewhere else, so look for a line or a wait that got worse. When the run-order warning is showing (B ran on a heap that A filled), the check says so instead, and you should restart and profile B again before trusting it.

Fix once, faster on every page

A slow line in a shared helper (a price formatter, an i18n lookup) shows up in the report of every page that calls it, and the dashboard adds these up. From the latest report of each page this server profiled, it lists the lines that two or more pages share and what fixing each one gives back per render, per page and in total (”catalog.ts:34, a key found by walking every entry: /deals ~49 ms, /search ~18 ms, ~67 ms in all”). A pattern’s saving is split over its lines according to what each cost, so a line gets its own share rather than the whole pattern’s. Keeping answers and caching a whole page are per-page decisions, so they are left out.

A report’s JSON and the agent’s digest carry the same list for that page (shared: “These fixes make other pages faster too”), so an agent fixing one page knows which fix pays off twice.

Holes on this server

The dashboard lists every hole the server has answered since it started, with requests, cache hits, failed renders, the mean render time, the mean wait for a render slot (with the worst in brackets), and each hole’s share of the time the slots were held. A server process renders 4 holes at a time, and a render holds its slot through its awaits, so a hole waiting on slow data keeps the next hole (for any visitor) in line. When holes had to wait, a line above the table says how many, for how long, and which holes held the slots, and that is the first place to look.

On a host that runs one request per instance (AWS Lambda), nothing waits for a slot, but the table still shows each hole’s render and cache figures.

Compare two reports, and history

Every page-mode report of the same page joins that page’s history. The dashboard draws the medians over time and marks any jump, and each report links to compare with the previous run. The compare page puts any two reports side by side, with summary deltas, phases, every component and function sorted by the size of its change, and the findings that appeared or went away. A positive delta is shown in red, meaning the second report is slower.

The compare page also diffs the paths to fix, listing each path by its owner and file, the time it cost in each report, the functions on it, and whether it is new in the second report or gone from it. A path that appears in the second report with its whole cost names the regression in one line, and a path whose cost doubled while its function list grew by one tells you which function did it.

Replay a caught request

A report from the trap (marked caught on the dashboard) and a report you profiled with the header both carry a replay button. It renders the same path again with the same query and request headers under the full profiler, then opens the new report. That turns the p99 request the trap caught in production with its coarse sampler into a proper profile with stacks, calls and heap, as long as the request is reproducible. The headers that come along are the ones trap.replay names (none by default, and never cookies unless you name them), so a page that depends on a tenant or a language replays with the same inputs once you list those headers.

CPU by self time

This is an interactive treemap in which each box is real work, sized by time, so the largest box is the bottleneck. Click a box to zoom in, and use the breadcrumb to zoom out.

It is built from self time, so structural parents like _layout and Root barely show and only code that burns CPU takes up space. Hover over a box to see its file and cost.

Components: self vs total

These two numbers are easy to confuse:

  • self is the time spent in the component’s own code, meaning its script and the building of its own HTML. It does not count nested components.
  • total is self plus every component and function it calls.

The root component of a page always has a large total and a small self, because its total contains the whole page. That is expected and not a problem.

  • Sort by self to find the components that burn CPU. This is the default.
  • Sort by total to find the most costly subtree, which helps when the cost is spread across many small children, such as a long list of rows.

The inline bar on each row shows self (bright) inside total (dim), so you can see at a glance whether a component is slow itself or slow because of what it renders.

Two more columns answer the next questions you are likely to have. markup / logic splits the component’s own time into Svelte writing its template (escaping, attributes, pushing markup) and its script plus the code it calls; nested components fall into neither. A row that is mostly markup has a big template, and the fix is fewer elements per instance rather than a faster script. under names the component that rendered most of it, which for a row is the owner of the {#each}. ”ProductCard ×48 under _page” tells you where the list lives, and the finding for a hot list points at the parent.

Click a row to open it. You get the full path with line and column (an open in editor link when the path is on your machine and a copy button otherwise, and on the dev server the source lines themselves with the hot line marked), along with the heaviest call stacks that rendered it, nearest caller first, each with the time that flowed through that path. That answers “where is this, and who renders it?” without the flame graph. The filter box above the table narrows the rows by name or file.

A component, for this table, is the function Svelte compiles a .svelte file into, which is named after the file. A capitalised name is not enough on its own, because a class in your code (IntersectionObserver, an Error subclass, a Store) uses the same case. A name from a bundled chunk therefore counts as a component only when its subtree reaches Svelte’s server internals, which rendering anything does. Everything else is app code.

Renders and per call

A component’s cost often comes from repetition rather than from one heavy render. A ×N tag next to a name means it ran N times in one page render (the count comes from one extra render that counts calls), so HeavyRow ×800 is eight hundred cheap renders, not one slow component. No tag means it ran once.

The per call column makes this explicit: it is the time per page render divided by N, which is the cost of a single call. The time adds up every profiled render while N is one render’s count, so both are brought to a single render first. A component can top the table on self and total and still sit near the bottom on per call, which is the “thousand small rows” shape, and the fix there is to render fewer rather than to make one faster. A component that is high on per call is the one that is genuinely heavy to render once.

Hot functions

This table lists every function on the server, sortable by self, total or per call. It shows where the CPU went below the component level, such as a formatter constructed per row, a regex pass or a serializer, and the file, line and column point you straight at it. The same ×N tag flags a hot loop (escape_html ×7800) as opposed to one slow call.

A row from a dependency names the package in its chip (devalue, @sveltejs/kit), so you can place a hot third-party function at a glance. Click a row to see the full path (which opens in your editor when it is a local file) and its call stacks, the three paths that account for the most time in it, nearest caller first. escape_html is hot in every profile, and the stack tells you which component’s loop calls it. The filter box narrows the table by function, file or package.

Native runtime built-ins (Node and V8 internals such as existsSync or the UTF-8 codecs) have no source file, so their rows are labelled node core with a native location. That is the runtime doing low-level work, not your code.

Hot lines. When you open a row, the profiler shows where inside the function the self time landed. V8 records a sample count per line, and the report turns it into “line 41: 38 ms, line 12: 4 ms”, mapped through the sourcemap when one resolved. On the dev server the source preview widens to cover those lines and shades each one by its heat, so a 40 ms function points at the one loop or regex that costs it.

Network

This lists every outbound fetch and http/https call the render made, tied to the route that made it, with a waterfall on a real timeline. Expand a request to see its payload as formatted, syntax-highlighted JSON, rendered lazily and off the main thread so that a multi-MB body opens without freezing the page. It also shows the wire and decoded sizes and the encoding. The waterfall shows ONE representative render rather than the same few calls repeated for every run.

The classic slow-page shape is easy to spot here: several calls stacked one after the other with no overlap, which is what sequential awaits look like. The report flags it, and the usual fix is to run the calls together:

// slow: ~sum of all three
const a = await getA();
const b = await getB();
const c = await getC();

// fast: ~the slowest one
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);

A call with a large “wait” but a small “body” points to a slow upstream service, while a large “body” points to a slow or oversized response.

Waiting by function

The waterfall covers outbound HTTP, but a render also waits on things HTTP never sees: a file read, a setTimeout, a DNS lookup, a raw socket. This table times that non-HTTP I/O with async_hooks and attributes each wait to the nearest frame in your code, so a slow readManifest shows up as the function that waited rather than as a featureless slice of “idle”.

Each row gives a function, its kind of wait (timer, fs, dns or tcp), how many times it waited, and the total. The table is sortable and has bars. When the budget bar is mostly idle but the network waterfall looks clean, the missing time is usually here.

Memory

This section shows the top allocators (who creates the objects, and therefore the garbage), RSS over the window, and precise GC pauses from the runtime. A long GC pause freezes every request in flight at the same moment, so when GC is a large slice of busy time, the allocators table tells you who is responsible.

The allocators table is grouped by component when the profiler can tell. Each allocation is charged to the nearest component on its stack (an island, a page, a layout), so ProductCard: 4.2 MB is what rendering that component allocated across the window, rather than the name of some inner helper. A component that allocates far more than it renders is building something it throws away, such as a big intermediate array, a string it concatenates in a loop, or an object per item that a $derived recomputes.

Who caused the GC

A page report connects the GC pauses to the garbage that caused them. The recorder runs V8’s sampling allocation profiler across the window and keeps already-collected objects in the profile, so the single read at the end gives the whole allocation stream by stack rather than only what survived. The garbage makers are ranked by the pause time they caused, on the basis that each allocator’s share of everything allocated is its share of the pauses. A builtin such as replace or structuredClone is named by the app line that called it, with the component on the stack, source mapped. The same line reached through different stacks is one row.

Each pause is listed with when it happened, its kind (a minor pause when the young space fills, a major one when the old space grows), how much was allocated since the previous pause at the window’s rate, and what the CPU was running at that moment according to the timeline. The timeline gets a red tick for each pause. A finding names the top garbage maker when it is worth fixing, and says whether the heap grew over the window (something keeps what it allocates) or not (pure churn).

The profile is deliberately read only once, at the end, because each read of the sampling profile during the work set off a major collection of its own, and a dozen reads made the collector being measured three times slower. The profiler’s own garbage is removed from every pause in proportion to its share of the allocations. On the hell page the answer came down to one line: the design system’s per-tag String.replace splices over a 3 MB document allocated 1.7 GB in three renders, two thirds of everything, and caused most of the pauses.

When the heap grew

The garbage makers table says who allocated over the whole window, and this section says when. The recorder reads V8’s used-heap figure every 20 ms while a full window runs (a heap statistic that needs no syscall and takes microseconds), and the report draws it across the whole recording, with the collections as bars and the render it explains highlighted. The stretches where the heap grew fastest are listed with what the CPU was running during them, by share, so a burst reads “+29 MB in 20 ms while toVM ran”. A timer only fires when the event loop yields, so each interval falls “between two awaits”: the samples carry their true times, and the longest gap between two samples is reported as the longest synchronous stretch the loop saw. A collection inside an interval shrinks the heap, so that interval’s growth is a lower bound, and the row says so. A finding (alloc-burst) names the stretch when one accounts for most of the growth. In the JSON it is alloc.

What a render leaves behind

This table comes from the same extra render described in Memory kept after every render. It names the lines that made the objects still alive after a forced collection, with the app caller, the component and the total per render. A page that keeps 30 MB per render grows until the instance restarts, and the finding says so and where the kept objects come from. What keeps them alive is their owner, usually a module-level cache or a registry.

Deoptimizations

V8 can record in the CPU profile why it threw a function out of optimized code: “wrong map” (objects of different shapes at one site), “not a Smi” (a number that changed kind), or a megamorphic call site. When it does, the report lists the functions deoptimized during the window with the reasons and how hot they are. A hot function that deoptimizes on every render runs in slow code on every render, and the reason names the assumption to fix. V8 reports this only for a function it gave up on inside the window, so one that re-optimized shows nothing, and the section stays hidden when there is nothing to show.

What if

Under the causality graph, What if… lets you change a call and see the render under a model of it: parallel starts the call together with the call it waited for, cache makes it instant, and remove takes it out. The render’s wall time is recomputed from the graph, with the longest remaining chain of waits spelled out. The CPU between calls stays as recorded and only the waits move. It is a model and labels itself as one, but “those eight product calls together: 412 ms becomes 260” is a plan you can test before touching any code.

Hidden costs

This section covers costs that no single hot function shows. Synchronous I/O inside the render (readFileSync, execSync, a sync compression) is listed with the app caller, because while it runs nothing else on the instance can make progress. Promises per render come from the async hook, which counts every promise and captures one stack in 256 for attribution; tens of thousands per render means an allocation and a microtask each, a cost spread too thin to show up as a function. Memoization candidates are app functions called many times per render at a steady cost each, shown with the allocation per call; if the result depends only on the argument, a cache keyed on it runs the function once per distinct value. That last one is a heuristic based on counts and cost, and the report says so.

For a page that streamed, the byte strip also shows when each chunk of the document left the server, and hovering over a segment shows the moment its bytes went out. The profiler requests the page the way a browser does (as a page navigation), so a load’s streamed promises stream during its render exactly as they do for a visitor. When the document paused for a long time before its last part, the report names what held it open: each streamed promise, by its page.data key, and when it left. Islands wake only once the whole document has arrived, so a slow streamed promise holds back every island on the page, including ones that read nothing late. The finding the document held open says so, with the islands that waited when a browser visited.

What the profiler takes out of every number

Every number in a report is what the app would have paid without the profiler. The profiler’s own CPU frames (the I/O tracker, the fetch wrapper, its reads) are measured and kept out of busy time, along with the Node work they call. A Node or V8 frame (such as the inspector’s dispatch or performance.now) counts as the profiler’s when its nearest caller outside Node and V8 is the profiler, while the same Node function under your code stays yours, and this applies to the timeline’s phases, the load lanes and the components. The profiler’s share of each GC pause is removed in proportion to its share of the allocations before that pause. Each render run loses exactly what fell inside it and nothing more, because the profiler’s start-up scan of compiled code (hundreds of milliseconds on a big process, before the first render) and its final reads sit outside the runs. The header shows what was taken out, and the JSON carries the runs both as reported and as the clock measured them (runs, runs_measured, summary.overhead). What cannot be seen from inside, namely the sampler’s own thread and the inspector’s, is not taken out, and amounts to a few percent.

A build without sourcemaps

With the profiler on, the server build writes hidden source maps on its own (see Sharper names with source maps), so this section is about a build that turned them off (build.sourcemap: false) or one made without the profiler. In such a build a frame names only its bundled chunk (chunks/frames.js:12). With the profiler on, the build also writes ogygia-modules.json beside the server chunks, recording which source module sits at which lines of each chunk. It travels with the build to whatever host runs it, and the profiler reads it to file each frame by what it is: the profiler’s own code (taken out, as described above), ogygia’s runtime and bundled packages (libraries), or your app.

A script module’s chunk lines are also matched to its source lines by their text. The build carries your src/ files and the chunks they sit in inside the same server module as the maps, so this works on a host that ships neither src/ nor the chunks as built (adapter-node re-bundles them). A line in Kit’s runtime, a package or ogygia’s own chunk is filed as what it is, so Kit’s fetch wrapper is never named as your caller. That covers loads, helpers and endpoints, but not components, whose compiled markup is not their source. The match survives what a build does to lines: stripped types, dropped comments, one statement wrapped over several lines, or two lines folded into one. A line the build changed beyond matching is placed by its distance from the nearest line that did match. Components keep the chunk’s line numbers (their {#each} is still read from the .svelte file), so build with sourcemaps if you need those.

The slow patterns work on a build without sourcemaps too. A server build is not minified, so the chunk’s lines read like your code (for (const r of reviews) best = Math.max(best, new Date(r.at).getTime());), and each line says which source module it came from: src/lib/format.ts (built chunks/format.js:131). A call made from inside a bundled package or Kit’s own runtime is never named as your line. A compiled component’s line is known by its .svelte module, so a table built on every render is still found, and memory a render keeps is charged to the built line that made it. The checks that need the original source file (a load’s line-by-line reading for “the same every request”, a late island’s data, one slow call in a batch, how many items a list renders) read it through the text match above, and a list’s {#each} through the module map’s .svelte file. On the test pages a build without sourcemaps finds every planted slow habit that a sourcemapped one does, and its forecast lands as close. Only where neither sourcemaps nor the source are available (a build made without the profiler on, whose output carries neither) does the forecast describe itself as partial: it is then a ceiling, and the real time after the fixes will be lower.

Flame graph and raw profile

This shows a flame graph of the merged call tree for the full window and offers a download of the raw .cpuprofile. Open that file in Chrome DevTools → Performance or at speedscope.app when you want to use their tools on the same data.

Type in the search box above the graph to find a function or file. Matching bars stay highlighted while everything else dims, and the count and summed time show how much of the graph the match covers. Pressing Enter zooms to the heaviest match.

The JSON report (/report/<id>.json) carries the same locations and call stacks (path, column and stacks on every hot function and component), so an agent reading it can locate a function without the UI.

One clock: from the click to the largest paint

A page report opens with the whole visit on one time axis. The server’s render (with its phases: hooks, load, render, ogygia) is placed so that it ends at the first byte of the document. After it come the HTML arriving, every file the browser fetched (one lane per kind: css, script, font, img and fetch, with render-blocking files dashed), the main thread’s long tasks, and each island waking, with module loading in light blue and hydration in green. The paints, DOM ready and load appear as dashed lines, and the first interaction inside each island as marks. The bars with a white outline form the critical path, which is listed under the drawing in order with the reason each bar is on it: the biggest part of the server render, the wait for the first byte, the last render-blocking stylesheet before first paint, and what the largest paint sat in (an image, or the island whose markup it is).

Each hole gets its own lane, showing its answer from the moment its request left, cut into the wait for a render slot on the server, the server render, and the network and swap. This is the same breakdown the devtools Page tab draws, produced by the same code. Copies of one component are numbered.

The visit comes from your own browser. The beacon records it while you look at the page (any page, while you are logged in to the profiler) and hands it both to the server and to the browser’s own store. A report on a long-lived host joins the server’s copy, and a report on an ephemeral host joins the browser’s copy, in the browser. When the profiled render is longer than the visit’s time to first byte (because of a cache or a faster instance), the server bar is clipped and labelled as such.

The document, byte by byte

This shows the HTML the server sent as a single strip, with every byte coloured by what it is: head markup, inline styles, stylesheet links, the runtime bootstrap, other scripts, the page seed, the remote seed, the island props tail, the holes record, each island’s markup (hover to see its name and size), lakes and holes, and plain markup, in the order the server wrote them. Declarative shadow DOM rendered by a design system is counted wherever it sits, including inside islands. The legend gives the bytes and share for each kind, and clicking a kind highlights only that kind. When a visit is known, hovering over a segment also tells you when it arrived at the browser, assuming a steady download. A seed that makes up a tenth of the document and sits in the middle of the body is a tenth of the download that everything after it has to wait for.

Who reads what: data lineage from the code

This puts every page.data key on one row with the load that returns it (read from the load’s source), the bytes the seed shipped for it, the time the load spent waiting on upstream calls per render, who reads it, and a verdict. The readers are found in the code itself: the route’s page and layouts, every component the profile saw that has a .svelte source on the machine, and the islands’ closures from the build. The scan walks the source character by character with comments and strings blanked out, without regular expressions, and recognizes data.key, data['key'], page.data.key, $page.data.key, and destructurings such as const { a, b: c } = data. A component that hands the object on whole (fn(data), { ...data }, Object.keys(page.data)) is marked as reading every key, but <Child {data} /> is not, because the child is scanned on its own.

The verdicts are read by an island (it must ship), server only, not shipped (the good case), server only, but shipped (rendered into the HTML and in the seed for no island, because the shaping could not see who reads it), nobody reads it (fetched for no one, so the load’s wait behind it is pure waste, and the key-unread finding says how much), and unknown (a reader could not be scanned, or an island takes the whole page). No key is called unread while any of its readers is unknown. The islands’ reads come from the page’s own seed explainer (the runtime’s answer per key), so the two sections agree. In the JSON it is lineage.

The data river

This draws where the page’s data comes from and where it goes as a flow in four columns: the upstream calls (with their waiting time), the load functions that made them, the page.data keys those loads return (with their bytes in the seed), and the islands that read each key. Band width shows the amount, in ms on the left and bytes on the right. A key in red is fetched and shipped for no one, because no island reads it. A load whose source the profiler could not read shows its keys as unknown, and an island that reads page.data through a helper the build could not follow takes the whole seed; the note under the river explains how to make the seed shrink.

The keys each load returns are read from its source (return { products, flags }), the keys each island reads come from the build, and the seed’s bytes per key come from the rendered document, for both a JSON seed and a devalue one (where a value shared by two keys counts for both).

Island cost heatmap

This puts every island on one grid, with a column for each cost: server render time, markup bytes, props bytes, unique JS, hydration time in your browser, when it finished waking during the visit, the first interaction inside it, and the layout shift its nodes caused. Each cell is shaded relative to the largest value in its column, and the rows are sorted by how many columns an island leads. An island that is dark across its whole row should be static or wake later, one that is dark only in JS needs a smaller closure, and one whose first interaction came seconds after it woke could wake on interaction instead. Click a column to sort by it.

DOM time travel

This shows each island’s markup at three moments (as the server sent it, after it hydrated, and when the page went away) with the diff between them, and draws the visit’s layout shifts on a page-shaped map together with the island that moved. When an island’s hydrated markup differs from the server’s, you see exactly which nodes changed, whether a design system inserted marks, a script rewrote text, or hydration re-rendered. The snapshots are large, so they live only in the browser that made the visit and never on the server. Open the page once while logged in, and the report then reads them back from that browser.

Nested traces: inside the upstream

While a recording runs, every outbound call carries a small request header. An upstream that also runs this profiler (with Server-Timing exposure on) answers with its own summary of that request in one response header: how long it took on its own clock, how much was CPU, how much was spent waiting on its own calls and how many there were, the route it ran, and its biggest calls. The waterfall’s drawer expands the call bar into that summary, so “this call took 300 ms” becomes “40 ms of which was the upstream’s CPU, 200 ms its own database, 60 ms network between us”, and two services end up in one report.

The whole site: the request cloud and the layer cake

/__profiler/site draws every request as a dot, with time across and duration up on a log scale, coloured by route, and with caught requests marked. Drag across a range and the hot functions from the always-on sampler windows inside it appear, which turns “why are 3 percent of product pages slow?” into a selection. Below it is the layer cake: for each route, the sum of its requests split into CPU, waiting on calls and everything else, sorted by total, with the count, p50 and p95. On a long-lived host these figures come from the instance’s own memory. On an ephemeral host they come from the sink, where every instance posts its rows to a URL you own, and the page reads them back over days from any URL you serve them from.

What the common slow patterns look like

  • Waiting, not computing: the budget bar is mostly idle, the CPU is low and the wall time is high. The answer is in the network waterfall, or in the waiting-by-function table for non-HTTP I/O such as files and timers. It may also be a database or socket driver that the profiler cannot see into, and the report says so when that is the case.
  • One heavy component: a single large box in the treemap, and that component tops the table when sorted by self.
  • Death by a thousand cuts: the treemap is a field of small boxes of the same colour. Sort the component table by total to find the parent that renders them all.
  • Allocation pressure: GC is a visible slice of busy time, and the allocators table names the cause.