Web Components Error Tracking: Lit Lifecycle Failures and Component Context
EngineeringOctober 29, 202613 min read

Web Components Error Tracking: Lit Lifecycle Failures and Component Context

Global handlers do see Lit and custom element exceptions, but they can't tell you which component failed. Here's how to add that context to lifecycle and slot errors.

Picture a dashboard component that works fine on its own. Works fine nested two levels deep. Then it goes inside a tab panel inside a modal inside a slot projection — and suddenly users see a blank rectangle where their analytics chart should be. Network requests complete. The component just... doesn't render.

Here's the usual cause: a custom element's connectedCallback throws because a parent context it expects isn't there. The exception does reach the window's error handler, so your tracker records it. But the report says something like TypeError: Cannot read properties of undefined with a stack trace into a minified bundle. Which component? Which route? Which lifecycle hook? Nothing in the report tells you.

This tutorial walks through adding that missing context in Lit and Web Components apps. We'll wrap custom element lifecycle methods, watch slot projection, cover third-party elements, and tag the reactive update failures that otherwise arrive as anonymous promise rejections. By the end, you'll have errors and pageviews flowing into JustAnalytics — no Sentry plus LogRocket plus analytics tool stack required.

What you'll have by the end

A Lit application with:

  • Component name and lifecycle phase attached to every captured exception
  • Custom element lifecycle instrumentation (connectedCallback, disconnectedCallback, attributeChangedCallback)
  • Lit updated failures reported with the component that threw them
  • Automatic pageview tracking that survives client-side routing
  • One platform covering analytics and errors

Prerequisites

  • Lit 3.x or any Custom Elements v1 implementation
  • A modern build setup (Vite, Rollup, or esbuild)
  • Node.js 18+ for development tooling
  • A JustAnalytics account — free tier covers 100K events/month
  • Basic understanding of the Custom Elements lifecycle and Shadow DOM

If you're using a different framework, check our SvelteKit error tracking guide or Vue/Nuxt setup. The patterns overlap, but the implementation differs enough to matter. For teams building with React or exploring full-stack observability, the core principles apply.

Step 1: Understand what global handlers already give you

Before writing code, let's be clear about the problem. Most error trackers, JustAnalytics' monitor script included, listen at the window:

window.addEventListener('error', captureError);
window.addEventListener('unhandledrejection', capturePromiseRejection);

An uncaught exception thrown from a custom element callback is reported to the window, shadow root or not. Lit schedules updates asynchronously, so a throw inside render() or updated() typically comes through as an unhandled rejection instead. Either way the global handler gets it. Shadow boundaries retarget DOM events; they don't hide script exceptions.

So the gap isn't visibility. It's attribution. A minified stack trace doesn't tell you the tag name, the lifecycle phase or the slot arrangement. Honestly, that's the more annoying problem: you have the error, and you still can't tell which of forty components caused it. The fix is to catch at the component boundary, add context, and rethrow.

Step 2: Create a context-aware reporting helper

We need one place that sends events and exceptions with component details attached. Here's a small utility:

// src/lib/component-error-tracker.ts
// The JustAnalytics script tags (tracker.js and monitor.js) are already on the page,
// so these helpers just call the globals they define.
declare global {
  interface Window {
    JustAnalytics?: { track(name: string, props?: Record<string, unknown>): void };
    JA?: { captureException(error: unknown, options?: Record<string, unknown>): void };
  }
}

export function sendEvent(name: string, properties: Record<string, unknown>) {
  window.JustAnalytics?.track(name, properties);
}

export function sendException(error: unknown, context: Record<string, unknown>) {
  window.JA?.captureException(error, { extra: context });
}

Optional chaining means nothing throws if the scripts haven't loaded or an ad blocker dropped them. One caveat: rethrowing after you report means the global handler may record the same exception a second time. Expect a duplicate and deduplicate by message and stack if it bothers you.

Step 3: Instrument Lit components automatically

Now we need lifecycle reporting on every Lit component. You could add try/catch to each component's connectedCallback by hand, but that's tedious and easy to forget. Let's create a mixin instead:

// src/lib/tracked-element.ts
import { LitElement } from 'lit';
import { sendEvent, sendException } from './component-error-tracker';

type Constructor<T = {}> = new (...args: any[]) => T;

export function TrackedElement<T extends Constructor<LitElement>>(Base: T) {
  return class extends Base {
    connectedCallback() {
      const startTime = performance.now();

      try {
        super.connectedCallback();

        // Track slow connections
        const elapsed = performance.now() - startTime;
        if (elapsed > 50) {
          sendLifecycleMetric('slow_connected_callback', {
            component: this.tagName.toLowerCase(),
            duration_ms: Math.round(elapsed)
          });
        }
      } catch (error) {
        sendLifecycleError('connected_callback_error', error, this.tagName);
        throw error; // Re-throw so the component fails visibly
      }
    }

    disconnectedCallback() {
      try {
        super.disconnectedCallback();
      } catch (error) {
        sendLifecycleError('disconnected_callback_error', error, this.tagName);
        throw error;
      }
    }

    updated(changedProperties: Map<string, unknown>) {
      try {
        super.updated(changedProperties);
      } catch (error) {
        sendLifecycleError('updated_error', error, this.tagName);
        throw error;
      }
    }
  };
}

function sendLifecycleError(eventName: string, error: unknown, tagName: string) {
  // captureException keeps the stack trace; the context tells you which hook failed
  sendException(error, {
    lifecycle: eventName,
    component: tagName.toLowerCase(),
    path: window.location.pathname
  });
}

function sendLifecycleMetric(eventName: string, properties: Record<string, unknown>) {
  sendEvent(eventName, { ...properties, path: window.location.pathname });
}

Apply the mixin to your finished component class, then register the result:

// src/components/my-dashboard.ts
import { LitElement, html } from 'lit';
import { property } from 'lit/decorators.js';
import { TrackedElement } from '../lib/tracked-element';

class MyDashboard extends LitElement {
  @property({ type: String }) userId = '';

  render() {
    return html`
      <div class="dashboard">
        <slot name="header"></slot>
        <main>${this.userId ? this.renderContent() : this.renderLoading()}</main>
      </div>
    `;
  }

  // ... rest of component
}

customElements.define('my-dashboard', TrackedElement(MyDashboard));

The order matters. The mixin wraps your class instead of serving as its base, so super.connectedCallback() and super.updated() inside the mixin run your component's own code within the try/catch. If you wrote class MyDashboard extends TrackedElement(LitElement), your component's connectedCallback and updated would override the wrapped versions, and the try/catch would only ever see Lit's internals.

Every component registered this way reports lifecycle failures with its tag name attached. The 50ms threshold for "slow" connections is arbitrary — tune it based on your performance budget. 50ms is a reasonable starting point, since delays much beyond that start to feel like lag.

Step 4: Catch slot projection errors

Slot errors are a special category of pain. When content projected into a slot references something that doesn't exist, or when slotted content expects a context that isn't there, the errors happen in a weird middle ground between light DOM and shadow DOM.

Here's how to track slot changes and catch related failures:

// Inside your TrackedElement mixin
firstUpdated(changedProperties: Map<string, unknown>) {
  super.firstUpdated(changedProperties);

  // Find all slots in this shadow root
  const slots = this.shadowRoot?.querySelectorAll('slot');

  slots?.forEach(slot => {
    const checkSlot = () => {
      const assignedNodes = slot.assignedNodes({ flatten: true });

      // Check for orphaned slot references
      if (assignedNodes.length === 0 && slot.name) {
        sendLifecycleMetric('empty_named_slot', {
          component: this.tagName.toLowerCase(),
          slot_name: slot.name
        });
      }
      return assignedNodes;
    };

    // slotchange never fires for a slot that never receives content
    // (a misspelled slot name, for example), so check once now too
    checkSlot();

    slot.addEventListener('slotchange', () => {
      const assignedNodes = checkSlot();

      // Track slot population for debugging
      sendLifecycleMetric('slot_change', {
        component: this.tagName.toLowerCase(),
        slot_name: slot.name || 'default',
        node_count: assignedNodes.length,
        has_elements: assignedNodes.some(n => n.nodeType === Node.ELEMENT_NODE)
      });
    });
  });
}

Empty named slots aren't always errors — sometimes they're intentional. But tracking them helps when content isn't showing up and nothing has thrown. A common cause is a typo in the slot name: slot="headr" instead of slot="header". It's an easy mistake with any design system built on named slots, such as Shoelace or FAST.

Step 5: Intercept third-party Web Component errors

You can't modify Shoelace's source. But you can intercept how it registers elements. This is admittedly a hack, but it works:

// src/lib/intercept-custom-elements.ts
import { sendException } from './component-error-tracker';

const originalDefine = customElements.define.bind(customElements);

customElements.define = function(
  name: string,
  constructor: CustomElementConstructor,
  options?: ElementDefinitionOptions
) {
  // Wrap the constructor
  const WrappedConstructor = class extends constructor {
    connectedCallback() {
      try {
        // @ts-expect-error - accessing parent class method
        super.connectedCallback?.();
      } catch (error) {
        sendException(error, {
          source: 'third_party_component_error',
          component: name,
          lifecycle: 'connectedCallback',
          path: window.location.pathname
        });
        throw error;
      }
    }
  };

  return originalDefine(name, WrappedConstructor, options);
};

Run this interception before any Web Components register — typically at the top of your entry point. It only wraps connectedCallback, so failures in other callbacks still arrive without a tag name. Extend the wrapper if a library you rely on is noisy. Closed shadow roots don't matter here, since we never touch shadowRoot, which is one reason this approach is sturdier than listening inside the tree.

Step 6: Add pageview tracking

Errors without context are frustrating. You need to know how many users hit each page to calculate error rates. You don't have to write a pageview tracker, though. The tracker script hooks pushState, replaceState and popstate itself, so client-side routes are counted without extra code. Add the two script tags once in your index.html (copy the exact snippet from your JustAnalytics dashboard), and the window.JustAnalytics and window.JA globals used above exist before your components connect.

Routers that navigate with history.pushState are covered automatically. If a router bypasses the History API, call window.JustAnalytics.trackPageview() yourself after each navigation. For teams running click tracking alongside this, ClickzProtect helps identify fraudulent traffic patterns before they pollute your analytics.

Common errors and how to fix them

Every error shows up twice. The wrapper reports, then rethrows, and the global handler records the rethrown exception too. That's intentional, because swallowing the error would hide a broken component from the console. Deduplicate on message plus stack in your dashboard filters.

Lit update errors never reach connectedCallback. Updates run asynchronously, so render() and willUpdate() failures don't pass through the lifecycle methods wrapped above. They surface as unhandled rejections. If you need the tag name on those, wrap the specific hook in the same try/catch pattern the mixin uses for updated.

Duplicate errors on hot reload. If your dev server re-runs the customElements.define interception from Step 5, each run wraps the already-patched define again, and components register with stacked wrappers. Restart the dev server; it's a dev-only issue.

Slot errors firing constantly. Some components update their slot content on every render, triggering slotchange events repeatedly. Add debouncing if it becomes noise. For most apps, this isn't an issue — but if you're rendering a list of 500 slotted items, you'll flood your event quota.

window.JustAnalytics or window.JA is undefined. Your component code ran before the script tags loaded, or an ad blocker dropped them. The optional chaining in the helpers means nothing throws, but nothing is recorded either. Load the scripts before your component bundle, and check the Network tab. For server-side event sending (like from a Node.js backend), check our Django middleware tutorial — the auth pattern differs.

What this won't fix

Let me be honest about the limits here.

Errors in CSS. If your Shadow DOM CSS has an invalid selector or broken @import, the browser ignores it silently. No JavaScript error fires. You need a separate CSS linting step in your build. Not an analytics problem.

Memory leaks from retained shadow roots. If you're not cleaning up event listeners when components unmount, you'll leak memory over time. Your component code needs proper cleanup in disconnectedCallback, and the browser's memory profiler is the tool for finding what was retained.

Render performance problems. Slow renders aren't errors — they're just slow. Track them separately with performance.mark() and performance.measure(). The slow_connected_callback metric above gives a rough signal, but that's just one lifecycle phase. For page-level numbers, JustAnalytics Pro includes Web Vitals alongside the errors. Set a baseline per component early, because a regression is hard to spot without one.

Errors in workers. Web Workers and Service Workers have their own global scope. Shadow DOM isn't relevant there, but if your component spawns workers, their errors need separate instrumentation. That's a different tutorial.

Next steps

You've got lifecycle instrumentation, slot signals, and pageviews flowing into one dashboard. Not complete observability — that would also include uptime monitoring, structured logs, and session replay — but a solid foundation.

From here, consider:

  • Setting up alerts to catch regressions after deploys
  • Adding session replay to see what users did before errors hit
  • Tracking slow API endpoints that your components depend on
  • Consolidating your five-tool observability stack if you're paying for Sentry, Datadog, and Pingdom separately

Web Components are great. Shadow DOM encapsulation is powerful — when it works. But that encapsulation fights you when things break. Now each failure arrives with a component name attached. Whether you should build your next project on Web Components? That's a different argument.

Frequently Asked Questions

Do generic error tracking SDKs catch errors thrown inside Shadow DOM components?

Usually yes. An uncaught exception in a custom element callback is reported to the window like any other, and a throw inside a Lit update typically surfaces as an unhandled rejection. Global listeners see both. What they lack is context: the stack trace points at bundled code and says nothing about which component, lifecycle hook or route was involved. Wrapping lifecycle methods lets you attach that.

How do I track errors in third-party Web Components like Shoelace or FAST?

You can't modify their source, but you can wrap their registration. Intercept customElements.define and wrap connectedCallback in try-catch so a failure is reported with the tag name attached. It's a monkeypatch, so test it against each library you use.

Where do Lit's reactive update lifecycle errors surface?

Lit runs updates asynchronously, so an exception in render(), willUpdate(), updated() or firstUpdated() typically surfaces as an unhandled promise rejection rather than a synchronous error. A global unhandledrejection listener sees it, but without component context. Wrapping the hooks you care about, as the mixin does for updated, adds the tag name and lifecycle phase.

Can I use this setup with Lit SSR and server-rendered Web Components?

Yes. Server-side rendering with @lit-labs/ssr generates declarative shadow DOM markup. Errors during SSR are Node.js errors — track them with the server SDK like any Express or Koa middleware. Client-side hydration errors are trickier: the declarative shadow DOM attaches before your JavaScript runs, so if there's a mismatch, you won't get a nice error. Add a post-hydration validation step that compares expected vs actual shadow DOM structure.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month, or $39/month billed annually.

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts