Skip to content

Profiler

Recording

How to capture a profile, either by profiling a page by URL or by profiling a single request with a header. Also covers the JSON output for agents, viewing reports from serverless hosts, shared report storage, the production secret, source maps, CI budgets and every option.

On this page

You can capture a profile in two ways. The usual one is on the dashboard at /__profiler, where you type a path and profile it; the other is a request header that you can send from anywhere.

Profile a page

Type a path, and the profiler renders that page several times in a row and profiles only those renders. This is the way in when you know which page is slow, which is almost always the case.

Before the measured renders it does one warm-up render without a profile, so the one-time cost of loading modules and filling caches falls outside the measured window and the median stays steady.

/__profiler → Profile a page → /some/slow/page

The page renders through your real server, which means everything it depends on (a session, cookies, a load) runs exactly as it does in production.

Profile a single request

Send a request with the header x-profile: <secret> and you get a profile of exactly that request. The report URL comes back in the x-profile-report response header.

curl -sD - 'https://example.com/slow/page' -H 'x-profile: your-secret' | grep x-profile-report

Because this starts a fresh CPU profile for the one request, the profiler’s start-up cost is added to that request’s latency. That makes it the right tool for catching one specific slow request, and the wrong one for steady measurement.

For agents and scripts

Every report is also available as curated JSON. This is the analyzed result rather than the raw V8 profile: self and total time per component, network attribution, memory, GC, and the same findings the human report shows.

# an existing report
GET /__profiler/report/<id>.json

# profile a page and get the JSON back in one request (no redirect, no HTML)
GET /__profiler/page?p=/slow/page&runs=6&format=json&key=<secret>

Sending Accept: application/json to /record or /page does the same. The payload describes itself with schema, version and units, so an agent can read it without any other context:

{
  "schema": "ogygia-profiler-report",
  "summary": { "window_ms": 699, "busy_pct": 100, "verdict": "compute-bound" },
  "findings": [{ "severity": "warn", "code": "sequential-network", "message": "…" }],
  "components": [{ "name": "PrimeSieve", "file": "src/…/PrimeSieve.svelte", "self_ms": 296, "total_ms": 296 }],
  "network": { "sequential_ms": 0, "hosts": [], "calls": [] },
  "memory": { "growth_mb": 195, "gc": { "max_ms": 2.2 }, "allocators": [] }
  // budget, hot_functions, files, requests, links…
}

Serverless and edge

The live dashboard expects a long-lived Node server, because it needs the V8 inspector and memory that persists between requests. Serverless Node platforms such as Lambda and Amplify can still record, since the inspector runs in-process, but the report lives in memory on an instance that may be gone before you open it. True edge runtimes such as Workers have no inspector at all, so nothing beyond the always-on request log is recorded there.

On a serverless host you take the report with you. Each report downloads as a self-contained JSON dump that holds everything needed to render it, with no inspector required to read it back:

# a one-shot page profile, straight to a dump
GET /__profiler/page?p=/slow/page&runs=6&format=dump&key=<secret>

# or an existing report
GET /__profiler/report/<id>/dump

Then open /__profiler/view anywhere, whether on your production route or on the same app running on localhost, and upload the file. The full HTML report renders from the dump alone, because the renderer is a pure function of it.

If you would rather not carry files around, give the profiler a shared store. Every instance then writes its reports to the same database, and any instance can show them. On such a host, also set onDemand: true, so that a cold start does not pay for the profiler until someone opens it.

In production

In production the UI stays off and answers 404 until you set a secret:

OGYGIA_PROFILER_SECRET=some-long-random-string

Then open the dashboard and log in with the secret. The profiler sets a session cookie, so links inside a report keep working for the rest of the session without asking for the secret again. The secret is compared in constant time, and it never travels in a URL, where a log, a cache or a shared link could keep it. The profiler is also reachable programmatically over MCP, with ogygia mcp / ogygia ai.

Serverless hosts (Amplify, Lambda). These hosts hand console variables to the build only, and the running function does not see them. Pass the secret through the vite config, as shown under Options, and keep the option unconditional. A guard like devtools({ profiler: env.SECRET && { secret } }) turns the whole profiler off when the variable is missing at build time, and /__profiler then falls through to your own routing (typically an i18n redirect to the home page), which looks like a broken profiler. A configured profiler with an empty secret instead answers /__profiler with a plain “installed but disabled” 404 and the build prints a warning, so the two cases are easy to tell apart.

Sharper names with source maps

Production bundles rename and inline code, so without source maps a stack frame points at a chunk file. With the profiler on, the server build writes hidden source maps on its own: .map files beside the server chunks, with no //# sourceMappingURL comment, and nothing for you to set up. The profiler reads them to map each frame back to its source file, line and original name, on whatever host runs the build.

  • The maps stay on the server. A browser never loads a server chunk. The client build also makes hidden maps, but they never reach a deploy: as soon as the client build has written its output, ogygia moves them into .svelte-kit/ogygia-client-maps/, a folder no adapter copies. The server module described below keeps the maps for chunks that contain your own code, and then the folder is removed. Nothing in the client output points at a map, and no map is served.
  • Browser traces get your lines too. A browser CPU trace from a build names minified functions in hashed chunks. The profiler maps them through the kept maps in the same way it maps a server frame, whether the host has the client files or knows only their URLs, so a slow click reads save (SlowSave.svelte:5).
  • The cost is disk space. The playground’s server output grows from about 6 MB to 12 MB.
  • Your own setting wins. If you set build.sourcemap yourself (false to turn maps off, true for maps everywhere), ogygia leaves it alone.
  • The maps survive any adapter. An adapter copies what the server code imports, not files that are read by path. Vercel’s adapter leaves the .map files behind, and adapter-node re-bundles the chunks with maps of its own that point back at files it does not ship. To cover both, the build also writes the maps (along with which source module sits at which chunk line) into one server module that only the profiler imports, the first time it reads a profile. That module ships with every adapter, no request ever loads it, and the profiler follows an adapter’s own map through it back to your source. It also carries your source text, so the checks that read a load or a component line by line work on a host that has no src/ folder.
  • That module stays small. It keeps the source text of your own src/ code only (not your packages’ text, and not imported .json data), and it is gzipped: a 34 MB set of maps on a large app ships as 4 MB, and reading a profile unpacks it once into about 11 MB of memory.

When this worked, the report header shows sourcemapped.

On the dev server

vite dev works too and gives the same findings. Vite runs each server module as transformed code built from a string, which hides where the code came from in four ways, and the profiler handles each of them:

  • Lines. A profile’s line numbers refer to the transformed code. The maps back to your source live in Vite’s module graph rather than in .map files, and the ogygia plugin hands them to the profiler.
  • Callers. A stack frame in such code has no file name, only its sourceURL, which the profiler reads instead.
  • Call counts. Coverage names such a script /app/x.ts while the CPU profile says file:///app/x.ts, so the counts are matched by script id.
  • Memory. The memory sampler leaves such a script unnamed, and the profiler names it from the CPU profile.

Vite’s pre-bundled dependencies (node_modules/.vite/) are filed under the package they belong to, including Svelte’s own runtime, even when it sits in a shared chunk named something like dev-Cl0b.js.

Which page.data keys each island reads is normally answered by the build, since the dev server ships the seed whole. On the dev server the plugin runs the same analysis the build does whenever the profiler asks, covering the island’s component, the app modules it imports, and a helper it hands the page to, one level deep. That is why “ships all page data” and “waits for a late island” work in dev too. The seed the dev server sends is unchanged, and its timings are the dev server’s, so compare forecasts on a build.

Cost and safety

  • Idle cost is almost nothing. The CPU is sampled only during a recording, at about 1–3% while it runs. Between recordings nothing is sampled unless you turn on the trap or always-on sampling described below. Per request the profiler reads two CPU counters and a clock and pushes one entry into a ring buffer, which takes microseconds. A cold start pays for one import of the profiler’s chunk (about 30 ms).
  • Always-on work per request consists of wall timing, a capped in-memory request log and, unless you turn network capture off, one AsyncLocalStorage wrap plus a fetch/http patch to attribute outbound calls. Pass { network: false } for the leanest path.
  • Server-Timing headers expose internal timings to every client, so by default they are on in dev and off in production. Pass { serverTiming: true } to force them on.
  • Recorded profiles stay in memory (the last few, gzipped). They are written somewhere else only when you give the profiler a store.

Options

Pass an object in place of true to the profiler option of the devtools() plugin:

// vite.config.ts
import { devtools } from '@ogygia/devtools/vite';

// listed beside ogygia(): plugins: [ogygia(), devtools({ … }), sveltekit()]
devtools({
  profiler: {
    secret: process.env.OGYGIA_PROFILER_SECRET, // default: the OGYGIA_PROFILER_SECRET env var
    path: '/__profiler',                        // UI base path
    sampleInterval: 500,                        // µs between CPU samples
    maxReports: 6,                              // profiles kept in memory (gzipped)
    recentRequests: 500,                        // requests the always-on request log keeps
    network: true,                              // patch fetch/http to attribute calls
    serverTiming: undefined,                    // default: on in dev, off in production
    heap: true,                                 // sample heap allocations while recording
    enabled: true,                              // master switch
    onDemand: false,                            // load the profiler only once /__profiler is opened (see below)
    trap: { over: 800 },                        // catch the slow one (off by default; see below)
    sample: { every: 60, window: 2000 },        // always-on sampling (off by default; see below)
    clientCpu: true,                            // the beacon records the browser's main-thread CPU (Chromium)
  },
});

trap.replay names the request headers the trap keeps with a caught request, so that a replay carries the same inputs (for example ['accept-language', 'x-tenant']). It keeps none by default, and never keeps cookies unless you name them.

onDemand: true keeps the profiler out of the process until a request for its path arrives. Until then the profiler’s module is not even imported, so the app pays what an app without a profiler pays: no import on a cold start, no request log and no per-request timing. After the first request to /__profiler, that instance runs the profiler as usual. The cost is history: the request log, the trap and the sampler start from that first visit rather than from boot, and a request sent with the x-profile header is profiled only by an instance that has already loaded the profiler. Use it on serverless hosts such as Lambda and Amplify, where every cold start would otherwise pay for the profiler’s import on its response time. It is off by default.

Catch the slow one

Profiling a page only helps with a page you already suspect. The p99 request you cannot reproduce needs the profiler to be watching at the moment it happens. With trap: { over: 800 }, a coarse sampler runs in rolling windows (5 s each, sampling every 10 ms) and keeps a window only when a request inside it took longer than over ms. The report is labelled caught on the dashboard, its timeline is that request’s, and the finding that names the slow step still works.

The trap is designed to stay cheap. It uses the coarse interval, keeps no per-request context (the request is matched to the window by time, so no AsyncLocalStorage runs), captures no stacks on calls, and does no heap sampling, which comes to about 1 to 3 percent CPU while it watches. It stops after keep reports (3 by default) and never runs on a serverless host, where an instance lives for one request anyway. Its options are over (ms, required), interval (ms, default 10), window (ms, default 5000) and keep (default 3).

Always-on sampling

sample: { every: 60, window: 2000 } takes one 2 s coarse sample every minute and folds it into a rolling hot functions table on the dashboard, covering the last hour of windows. Its accuracy comes from volume: a function that is hot across sixty windows is hot in real traffic, whatever a single recording of a single page shows. It costs the same as the trap, since the two share the recorder and take turns, and it is off on serverless hosts.

In CI: budgets and the first bad commit

ogygia perf renders a list of pages under the profiler on a running server (a local preview in the workflow, or a deployed preview), writes a snapshot, and judges it against a baseline under a budget. It exits 1 when the budget fails and writes the PR comment as markdown.

# the baseline: the same command on main, its snapshot kept as an artifact or committed
npx ogygia perf --url http://127.0.0.1:4173 --pages /,/products,/account --runs 5 \
  --key "$OGYGIA_PROFILER_SECRET" --out perf-baseline.json

# the PR: measure, diff, comment, fail
npx ogygia perf --url http://127.0.0.1:4173 --pages /,/products,/account --runs 5 \
  --key "$OGYGIA_PROFILER_SECRET" --baseline perf-baseline.json --budget perf-budget.json \
  --out perf-snapshot.json --md perf-comment.md --label "$GITHUB_SHA"

For each page the snapshot keeps the median render and every run, CPU busy, waiting, the cold render’s extra time, the seed and island props in KB, the islands’ JS in KB (each unique module counted once), the top components by self time, the hot functions, the paths to fix, and the finding codes at warn level. The comment shows one table per page (base, PR, delta and budget), the three biggest movers with their files, and the findings that appeared or went away.

The budget is a JSON file in which every key is optional, with per-page overrides:

{
  "render_pct": 8, "render_floor_ms": 5,
  "busy_pct": 15, "component_pct": 25,
  "seed_kb": 20, "seed_max_kb": 300,
  "props_kb": 30, "islands_js_kb": 30, "islands_js_max_kb": 250,
  "cold_extra_ms": 100,
  "fail_on": ["seed-whole", "never-hydrated"],
  "pages": { "/": { "render_max_ms": 120 } }
}

Relative budgets have a floor, so a 2 ms page cannot fail on 1 ms of noise. The _max_ keys are absolute caps: a page that was always over one fails even with no growth, and a page with no baseline yet is judged by the caps alone. fail_on fails the build when a named finding appears. Run the profiler with runs: 5 or more in CI, because the median of five renders on a shared runner is stable to within a few percent and a single render is not.

The first bad commit. When a page got slower somewhere in the last N commits, ogygia bisect builds and profiles commits between a good one and a bad one in a binary search, each in its own git worktree, and names the culprit:

npx ogygia bisect --good v1.4.0 --bad HEAD --page /products \
  --build "pnpm install --frozen-lockfile && pnpm build" --start "node build" \
  --url http://127.0.0.1:3000 --key "$OGYGIA_PROFILER_SECRET" --runs 3

It measures the two ends first and sets the bar halfway between them (--margin 0.5), so noise below the bar cannot mislead the search. A commit that fails to build is skipped, and the search steps around it. Ten commits take four builds, and a hundred take seven.

Ephemeral hosts: the browser keeps the report

A serverless instance lives for seconds and forgets everything it measured. The profiler is designed around that: the server captures inside one request and hands the result over, and the browser acts as the database.

  • Profile a page answers the run page with the report’s dump, and the browser stores it (in IndexedDB, on the app’s origin) before opening the report. A report page that the server no longer holds renders from the browser’s store. The dashboard lists the reports this browser kept, and two kept reports of the same page can be compared in the browser.
  • The beacon writes each visit (navigation timing, resources, paints, islands, shifts, and the islands’ markup before and after hydration) into the same store, so the one-clock timeline, the heatmap and DOM time travel all work whether or not the instance that rendered the report still exists.
  • Every report page stores itself when the browser is idle if you opened it some other way (from a header profile’s link or a trap catch), so restarting a long-lived server loses nothing you have looked at.

Nothing is shared between browsers, because a report is kept by the browser that recorded or opened it. To move one, export the .ogp or a share link, or give the profiler a shared store, described next.

Shared report storage

A finished report lives in the memory of the instance that recorded it. On one long-lived server that is all you need, but it falls short in two common setups. On a serverless host, the instance that recorded a report is often gone before you open it. Behind a load balancer, the request for the report page can land on an instance that never saw the recording. In both cases each instance’s dashboard lists only its own reports, so teammates never see each other’s work.

A store solves this. You give the profiler one in src/hooks.server.ts, and from then on every finished report is written to it and read back from it. A report then outlives the instance that recorded it, survives a restart, and shows up in the dashboard of every instance:

import { setProfilerStore } from '@ogygia/devtools/profiler/storage';
import { postgresStore } from '@ogygia/devtools/profiler/storage/postgres';

setProfilerStore(postgresStore(process.env.DATABASE_URL!));

// your handle stays as it was

The store goes in the hooks file rather than in the profiler options because it is a live object holding a database connection, while the options in vite.config.ts are build-time values. Call setProfilerStore() at the top level of the module. The profiler reads the store once, when it loads on the first request it handles (with onDemand, the first request to /__profiler), and it ignores a call made after that.

Memory stays the fast path. A report is still kept in memory and is also written to the store, and a report that is not in memory is read from the store and cached. A store that fails never breaks a recording: a failed write leaves the report in memory only, and if the store cannot be set up when the profiler loads, the profiler logs the error and runs without it. The store also keeps the requests of a test’s server window, which is how a session recorded across several instances comes back whole.

The bundled stores

Each store is a factory in its own entry point, so the database client you do not use is never loaded:

Entry pointFactoryWhat it is
@ogygia/devtools/profiler/storagememoryStore(cap = 50)An in-process map that keeps the newest cap reports. Nothing survives a restart, so it adds nothing to the default; it is there for tests and as a model for your own store.
@ogygia/devtools/profiler/storage/sqlitesqliteStore(filename = '.ogygia/profiles.db')One SQLite file (relative to the working directory, its folder created for you), or ':memory:'. Needs Node 22.5 or later, which has node:sqlite built in, or the better-sqlite3 package. It survives a restart, but it is local to one machine.
@ogygia/devtools/profiler/storage/redisredisStore(client, { ttlSeconds, prefix = 'og:' })A Redis URL (it imports redis, or else ioredis) or a client you already connected (either library). ttlSeconds expires reports on their own, and keys start with prefix. It keeps at most 1,000 reports.
@ogygia/devtools/profiler/storage/postgrespostgresStore(client, { schema, table = 'ogygia' })A connection string (it imports pg) or a pg pool you already have. It creates the <table>_reports and <table>_visits tables on first use, inside schema when you name one. The report is stored as jsonb, so you can query inside it.

The database client packages (better-sqlite3, redis or ioredis, pg) are not dependencies of @ogygia/devtools, so install the one your store needs. For a serverless host, pick Redis or Postgres, since every instance has to reach the same store; SQLite suits a single long-lived server.

After each write, the profiler trims the store to its newest maxReports × 8 reports, and never to fewer than 200. A window’s requests are trimmed by the store itself: SQLite, Redis and Postgres each keep the newest 2,000 per window, which is as many as the window reads back when it stops.

Writing your own store

A store is any object that satisfies the ProfilerStore interface from @ogygia/devtools/profiler/storage. Every method may return a value or a promise, so a network backend fits as well as a local one:

MemberRequiredWhat it does
kindyesA name for the store, such as 'kv'.
putReport(record)yesWrites a report, or overwrites the one with the same id.
getReport(id)yesReturns the report’s dump string, or null when there is none.
listReports(limit?)yesReturns the newest reports first, as records without their dump.
deleteReport(id)yesRemoves a report.
init()noRuns once when the profiler loads: connect, create tables.
prune(keep)noKeeps the newest keep reports and drops the rest. Called after each write.
putVisit(visit) · listVisits(page, limit?)noWrite and read the requests of a server window. Without them, a window sees only its own instance’s requests.
close()noReleases connections.

A record (ProfilerRecord) has a few summary fields for the list, id, created (epoch ms), page, trigger, label and median, plus dump: the report’s portable JSON as a string, the same data the .ogp export and the dump download carry. A visit row (ProfilerVisitRecord) is { key, page, at, visit }, where page groups the rows that listVisits returns together and visit is also a JSON string. A window writes all its requests under one page and asks for up to 2,000 of them, so keep at least that many rows per page. Nothing in a record is encrypted, because a store is infrastructure you run yourself, so treat its contents like the profiler’s own pages.

The sink: the whole site from ephemeral instances

The request cloud and the layer cake need rows from many instances over a long period, so each instance needs somewhere to post them:

devtools({ profiler: {
  sink: { url: 'https://…/profiler-rows', key: process.env.PROFILER_SINK_KEY, every: 10_000 },
  serverTiming: true // also lets an upstream running this profiler answer nested traces
} })

Each instance posts NDJSON rows: one small row per request (path, route, ms, CPU, waiting and status), one summary per always-on sampler window (the hot functions), and a caught request’s dump. key is sent as a bearer token and defaults to the profiler secret. A long-lived host posts every every ms, and a serverless one posts after each request on a best-effort basis. Serve the collected rows from any URL the browser can read (a bucket or an endpoint) and paste that URL into /__profiler/site.