Why Frontend Observability Matters
Backend observability is mature — distributed tracing, structured logging, metrics dashboards. Frontend observability is catching up. In 2026, production frontend issues cause measurable business impact: a 100ms increase in page load time reduces conversion by 1%. A JavaScript error blocking checkout loses revenue immediately.
This guide covers the full frontend observability stack: Real User Monitoring (RUM) for performance, error tracking with Sentry, custom instrumentation, and performance budgets enforced in CI.
Core Web Vitals: The Metrics That Matter
Google's Core Web Vitals are the canonical frontend performance metrics, tied directly to search ranking since 2021:
- LCP (Largest Contentful Paint): How long until the main content is visible. Target: < 2.5s
- FID/INP (Interaction to Next Paint): Responsiveness to user input. Target: < 200ms (INP replaces FID)
- CLS (Cumulative Layout Shift): Visual stability during load. Target: < 0.1
// Collect Core Web Vitals with web-vitals library
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals'
function reportWebVital(metric: Metric) {
// Send to your analytics endpoint
fetch('/api/vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
// Include page context
page: window.location.pathname,
userId: getCurrentUserId(),
sessionId: getSessionId(),
}),
// Use keepalive so the request survives page unload
keepalive: true,
})
}
onLCP(reportWebVital)
onINP(reportWebVital)
onCLS(reportWebVital)
onFCP(reportWebVital)
onTTFB(reportWebVital)
Real User Monitoring with Custom RUM
Library-based RUM gives you aggregate data. Custom RUM gives you user-specific context:
// rum.ts — lightweight RUM implementation
class RUM {
private sessionId = crypto.randomUUID()
private marks: Map<string, number> = new Map()
private measures: PerformanceEntry[] = []
mark(name: string) {
this.marks.set(name, performance.now())
performance.mark(name)
}
measure(name: string, startMark: string, endMark?: string) {
const start = this.marks.get(startMark) ?? 0
const end = endMark ? (this.marks.get(endMark) ?? performance.now()) : performance.now()
const duration = end - start
this.measures.push({ name, duration, startTime: start } as PerformanceEntry)
// Alert on slow interactions
if (duration > 1000) {
this.send('slow-interaction', { name, duration })
}
return duration
}
// Track route changes in SPAs
trackRouteChange(from: string, to: string) {
const start = performance.now()
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const duration = performance.now() - start
this.send('route-change', { from, to, duration })
})
})
}
private send(event: string, data: Record<string, unknown>) {
navigator.sendBeacon('/api/rum', JSON.stringify({
event,
data,
sessionId: this.sessionId,
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent,
}))
}
}
export const rum = new RUM()
// Usage in React
function ProductPage({ productId }: { productId: string }) {
useEffect(() => {
rum.mark('product-page-mount')
return () => {
rum.measure('product-page-session', 'product-page-mount')
}
}, [])
const { data, isLoading } = useProduct(productId)
useEffect(() => {
if (data) rum.measure('product-data-loaded', 'product-page-mount')
}, [data])
return <div>...</div>
}
Sentry Integration: Error Tracking in Depth
npm install @sentry/react
// main.tsx — Sentry initialization
import * as Sentry from '@sentry/react'
import { BrowserTracing } from '@sentry/tracing'
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
release: import.meta.env.VITE_APP_VERSION,
// Performance monitoring — sample 20% of transactions in prod
tracesSampleRate: import.meta.env.PROD ? 0.2 : 1.0,
// Session replay — record user sessions on errors
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
),
}),
new Sentry.Replay({
maskAllText: true, // GDPR: mask sensitive text
blockAllMedia: false,
}),
],
// Add user context when available
beforeSend(event) {
const user = getCurrentUser()
if (user) {
event.user = { id: user.id, email: user.email }
}
return event
},
})
Structured Error Boundaries with Sentry
// components/ErrorBoundary.tsx
import * as Sentry from '@sentry/react'
export const ErrorBoundary = Sentry.withErrorBoundary(
({ children }) => <>{children}</>,
{
fallback: ({ error, resetError }) => (
<div role="alert" className="error-container">
<h2>Something went wrong</h2>
<p>Our team has been notified. Please try again.</p>
<button onClick={resetError}>Try again</button>
{import.meta.env.DEV && (
<details>
<summary>Error details</summary>
<pre>{error.message}</pre>
</details>
)}
</div>
),
onError: (error, componentStack) => {
Sentry.captureException(error, { extra: { componentStack } })
},
}
)
// Wrap specific sections independently
function App() {
return (
<ErrorBoundary>
<Header />
<ErrorBoundary>
<main>
<Routes />
</main>
</ErrorBoundary>
<Footer />
</ErrorBoundary>
)
}
Custom Sentry Context
// Add breadcrumbs for better error context
function handleUserAction(action: string, data: Record<string, unknown>) {
Sentry.addBreadcrumb({
category: 'user-action',
message: action,
data,
level: 'info',
})
}
// Manual performance spans
async function loadDashboardData() {
const transaction = Sentry.startTransaction({ name: 'dashboard-load' })
Sentry.getCurrentHub().configureScope(scope => scope.setSpan(transaction))
try {
const span = transaction.startChild({ op: 'http.client', description: 'GET /api/stats' })
const stats = await fetchStats()
span.finish()
const span2 = transaction.startChild({ op: 'data.transform', description: 'process stats' })
const processed = processStats(stats)
span2.finish()
return processed
} finally {
transaction.finish()
}
}
Performance Budgets in CI
Performance budgets enforce that performance does not regress between deployments:
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:4173/', 'http://localhost:4173/dashboard'],
numberOfRuns: 3,
startServerCommand: 'npm run preview',
startServerReadyPattern: 'Local:',
},
assert: {
assertions: {
// Core Web Vitals thresholds
'categories:performance': ['error', { minScore: 0.85 }],
'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'interactive': ['error', { maxNumericValue: 3800 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
// Bundle size budgets
'resource-summary:script:size': ['error', { maxNumericValue: 400_000 }], // 400KB JS
'resource-summary:stylesheet:size': ['error', { maxNumericValue: 50_000 }], // 50KB CSS
},
},
upload: {
target: 'lhci',
serverBaseUrl: process.env.LHCI_SERVER_URL,
token: process.env.LHCI_TOKEN,
},
},
}
# .github/workflows/performance.yml
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_TOKEN }}
Source Maps for Production Errors
Without source maps, production errors show minified stack traces: at f.o (app.abc123.js:1:4892). With source maps, they show the original TypeScript: at handleCheckout (src/pages/Checkout.tsx:142:18).
// vite.config.ts
export default defineConfig({
build: {
sourcemap: 'hidden', // Generate but don't reference in HTML
},
})
Upload source maps to Sentry as part of your build process:
# Upload after build
npx @sentry/cli sourcemaps upload --org your-org --project your-project --url-prefix '~/assets' dist/assets
User Behavior Analysis
Understanding what users actually do complements error and performance data:
// analytics.ts — privacy-respecting behavior tracking
export const analytics = {
// Page views (automatic with most frameworks)
page(path: string, properties?: Record<string, unknown>) {
if (hasConsent('analytics')) {
sendEvent('page_view', { path, ...properties })
}
},
// Feature usage
track(event: string, properties?: Record<string, unknown>) {
if (hasConsent('analytics')) {
sendEvent(event, properties)
}
},
// Performance-sensitive user flows
startFlow(flowName: string) {
const start = performance.now()
return {
step(stepName: string) {
const elapsed = performance.now() - start
sendEvent('flow_step', { flowName, stepName, elapsed })
},
complete() {
const duration = performance.now() - start
sendEvent('flow_complete', { flowName, duration })
},
abandon(reason?: string) {
const duration = performance.now() - start
sendEvent('flow_abandon', { flowName, duration, reason })
},
}
},
}
// Usage in checkout flow
function CheckoutPage() {
const flow = useMemo(() => analytics.startFlow('checkout'), [])
const handleAddressSubmit = () => {
flow.step('address_submitted')
// ...
}
const handlePaymentSuccess = () => {
flow.complete()
// ...
}
}
Putting It Together: Observability Dashboard
With these pieces in place, you have:
- Sentry: Real-time error alerts with full context
- Lighthouse CI: Automated performance regression detection per PR
- RUM: Actual user experience data (not synthetic measurements)
- Web Vitals: Core Web Vitals aggregated by page and user segment
- Source maps: Readable production stack traces
The goal is not just to know when things break — it is to understand your users' actual experience before they tell you it is broken.