@magnolia/pulse
v0.1.0-beta.2
Published
Lightweight Core Web Vitals collector SDK for Magnolia Web Performance. Captures TTFB, FCP, LCP, INP and CLS and beacons them to your Magnolia Web Performance collector region.
Maintainers
Readme
@magnolia/pulse
Pulse is a lightweight JavaScript SDK that captures Core Web Vitals in a visitor's browser and beacons them to your Magnolia Web Performance collector - no RUM instrumentation to build yourself.
What it measures
Pulse wraps Google's web-vitals library to collect
all five Core Web Vitals:
| Metric | Name | Category | What it tells you | "Good" threshold | | -------- | ------------------------- | ------------- | --------------------------------------------------------------------------------------------------------- | ---------------- | | TTFB | Time to First Byte | Server | How fast your server responds - the time between the request and the first byte of the response arriving. | ≤ 0.8 s | | FCP | First Contentful Paint | Loading | The exact moment the user sees anything on the screen (text, background image, etc.). | ≤ 1.8 s | | LCP | Largest Contentful Paint | Loading | When the main content - the largest image or text block - finishes rendering. | ≤ 2.5 s | | INP | Interaction to Next Paint | Interactivity | How snappy the page feels when you click or type - the delay until the screen visually updates. | ≤ 200 ms | | CLS | Cumulative Layout Shift | Stability | Whether things jump around while loading (e.g. an ad pops in and pushes the text down). | ≤ 0.1 |
Hard navigations only - no SPA soft-navigation support
Pulse collects one batch of metrics per hard navigation (a real document load - the initial
page view, a reload, back/forward, or prerender). It does not re-collect metrics on client-side
route changes in single-page apps (SPA "soft navigations" via history.pushState), and that's
deliberate, not an oversight:
- TTFB and FCP are defined off the document's single navigation-timing / first-paint entry and are only ever emitted once per document, full stop - there is no browser-level concept of "TTFB" or "FCP" for a client-side route change.
- LCP stops accepting new candidates the moment the user has their first interaction (click/keydown) - which includes the very click that triggers a soft navigation. So by the time a new route renders, native LCP tracking for that document is already frozen; there's no "LCP for route 2" available from the standard Performance API.
- CLS keeps accumulating for the document's entire life regardless of soft navigations, which cuts the other way: an SPA swapping its whole view on a route change can look like one large, unintentional layout shift, inflating the score.
- INP is the one metric that keeps measuring correctly through soft navigations with no extra
work, since interaction/event-timing entries don't care about
pushState- it just stays one aggregate number for the whole session rather than being split per route.
nav_type (ours and web-vitals' own Metric.navigationType) reflects only how the hard
navigation started (navigate / reload / back_forward / prerender) and never changes
afterwards - there is currently no field, on the metric or in the collector schema, distinguishing
"from the initial load" vs. "from a subsequent SPA route." The browser's experimental
Soft Navigations API is the
eventual standards-track answer to this (Chrome-only, still behind flags/origin trial), but it
isn't broadly supported yet, so this SDK doesn't build on it. Revisit if/when a real SPA RUM
requirement shows up.
Network weight
Alongside the Core Web Vitals, Pulse also reports three network weight readings as additional
entries in the same metrics array:
| name | Source | What it tells you |
| ------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| REQUEST_COUNT | Count of resource entries + the document itself | How many separate network requests the page made - a proxy for waterfall/connection complexity. |
| TRANSFERRED_BYTES | Sum of transferSize across resource + navigation | Bytes that actually moved over the network this visit (compressed body + headers) - the real bandwidth cost paid. |
| RESOURCE_BYTES | Sum of decodedBodySize across resource entries | The decompressed content size - the page's actual weight, independent of compression or caching. |
They're WebVitalsMetric entries like LCP/FCP/etc., just without a rating or delta - there
is no industry-standard good/needs-improvement/poor threshold for them, unlike the Core Web
Vitals. They do carry nav_type, matching the page's overall navigation type. These are
diagnostic/causal numbers: they help explain why a page's Vitals look the way they do (e.g. poor
LCP + a high REQUEST_COUNT often points at render-blocking third-party requests), not perceptual
ones in their own right.
Two limitations worth knowing:
- Cross-origin resources under-report.
transferSize/decodedBodySizeare0for any cross-origin resource unless its response sends aTiming-Allow-Originheader permitting it - third-party fonts/ads/analytics that don't opt in are silently missing fromTRANSFERRED_BYTES/RESOURCE_BYTES(both are a floor, not an exact total).REQUEST_COUNTis unaffected. - The browser's resource-timing buffer caps historical lookups at 250 entries in most
browsers. Pulse avoids this by accumulating totals from a live
PerformanceObserveras entries happen, rather than readingperformance.getEntriesByType('resource')once at flush time - so counts stay accurate on request-heavy pages instead of silently dropping once the buffer fills.
Collection is aggregate-only by design (three numbers, not a per-resource breakdown) to keep the beacon small, and all-or-nothing: on a browser without resource timing support, all three are omitted from the batch entirely rather than sent as misleading zeros.
The numbers are frozen at the load event, not at flush time. flush() still only sends once,
on hidden/pagehide like everything else - but for a page that stays in the foreground for a
long session (a dashboard left open for hours, say) that could be arbitrarily later than when the
page actually finished loading. Without freezing at load, a page that keeps making background
requests for the rest of its session - e.g. polling a status endpoint every few seconds - would
have these numbers keep growing for as long as the tab stays open, turning "how much did this page
load" into "how chatty was this tab session," which isn't the question these metrics are meant to
answer. If the page is hidden/unloaded before load ever fires, whatever accumulated so far is
reported instead, the same "best effort as of pagehide" behavior as the Core Web Vitals.
Installation
Pulse can be installed two ways, depending on how the host site is built.
npm
npm install @magnolia/pulseimport Pulse from '@magnolia/pulse';
new Pulse({
key: '<CLIENT KEY GENERATED VIA MAGNOLIA PAAS COCKPIT>',
region: '<COLLECTOR REGION>',
metrics: true,
});<script> tag
For sites that don't run a bundler, load the IIFE build directly - it exposes a global Pulse
constructor with the same API:
<script src="https://unpkg.com/@magnolia/pulse/dist/pulse.iife.js"></script>
<script>
new Pulse({
key: '<CLIENT KEY GENERATED VIA MAGNOLIA PAAS COCKPIT>',
region: '<COLLECTOR REGION>',
metrics: true,
});
</script>Configuration
new Pulse({
key: string, // required
region: string, // required
metrics: boolean, // optional, default false
debug: boolean, // optional, default false
});| Option | Type | Required | Description |
| --------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| key | string | yes | The public collector key for your site, generated in Magnolia PaaS Cockpit. Sent as the X-Vitals-Key header on every beacon - it identifies the site and authorizes ingestion. |
| region | string | yes | Which collector region to send beacons to: integration, eu-central, us-east, or ap-southeast. For your region see in Magnolia PaaS Cockpit when the key is created. |
| metrics | boolean | no | true collects the full default set of Core Web Vitals above. false (the default) initializes Pulse without collecting anything. |
| debug | boolean | no | true logs collection/transport failures to console.error. Defaults to false - Pulse never writes to a real visitor's console unless you opt in while integrating. |
Future:
metricswill accept a configuration object instead of a plain boolean, so you can cherry-pick individual metrics or tune sampling.truewill keep meaning "everything, at default settings" once that lands.
Browser support & bundle size
Pulse is loaded on every visitor's page, so it's built with a strict size budget in mind:
- No runtime dependencies beyond
web-vitalsitself. - Tree-shakeable ESM/CJS builds for bundler consumers, plus a minified IIFE build for
<script>tag use. - Metric collection silently no-ops on browsers that don't support the underlying
PerformanceObserverentry types - the page is never blocked or broken.
Development
npm install
npm run build # emits dist/index.{js,cjs,d.ts} and dist/pulse.iife.js
npm run typecheck
npm testProject layout:
src/
index.ts Pulse class - wiring, buffering, flush lifecycle
regions.ts region -> collector base URL map
network.ts connection/device/viewport data, with schema-safe fallbacks
network-weight.ts live-observer request count / transferred / resource bytes tracking
session.ts sessionStorage-backed session id, 10 min idle timeout
send.ts fetch(keepalive) transport, fully fail-silent
id.ts shared id generator (session id, network-weight metric ids)
types.ts PulseOptions + collector request/response typesLicense
See LICENSE.
