Preact Error Tracking: Signals, Source Maps, Under 5KB
EngineeringAugust 7, 202612 min read

Preact Error Tracking: Signals, Source Maps, Under 5KB

Track Preact errors without bloating your bundle.

Last month I shipped a Preact widget that needed to load in under 15KB total. Framework, UI, business logic, everything. The widget worked beautifully — until a customer's CSP blocked a third-party API and the whole thing crashed without a sound. No errors in my dashboard. No alerts. Just a blank iframe and an angry email three days later.

The irony wasn't lost on me. I'd spent two weeks shaving bytes off the bundle, then skipped error tracking because Sentry's SDK would've doubled the payload. A 70KB error tracker for a 12KB app felt absurd. So I shipped without it.

Stupid.

This tutorial shows you how to add Preact error tracking without sacrificing the tiny-bundle advantage that made you choose Preact in the first place. We'll wire up JustAnalytics with an async script that stays under 5KB, handle @preact/signals-specific edge cases, and get source-mapped stack traces so you can actually debug production errors. If you're running paid traffic to a Preact-powered landing page, ClickzProtect pairs well here — when errors spike, you want to pause spend before burning budget on a broken page.

What you'll have at the end

A Preact application with:

  • Error boundaries that catch component-level crashes
  • Global error handling for uncaught exceptions and unhandled rejections
  • Signal-specific error tracking for computed and effect failures
  • Source map upload so minified stack traces resolve to actual line numbers
  • An async script load that doesn't touch your main bundle size

Prerequisites

  • Preact 10.x (tested on 10.22) with optional @preact/signals (1.2+)
  • A bundler that generates source maps — Vite, Rollup, esbuild, or Webpack
  • Node.js 18+ for the build toolchain
  • A JustAnalytics account — free tier covers 100K events/month

If you're on a different framework, we've got guides for SvelteKit, Django, Vue/Nuxt, and Next.js 15. The principles overlap but the wiring differs.

Step 1: Load the tracking script asynchronously

The key to preserving your bundle size is keeping the tracking script out of your bundle entirely. Load it async after your app hydrates:

// src/index.tsx or your entry point
import { render } from 'preact';
import { App } from './App';

// Mount your app first — tracking is secondary
render(<App />, document.getElementById('app')!);

// Load tracking script after hydration
if (typeof window !== 'undefined') {
  const script = document.createElement('script');
  script.src = 'https://cdn.justanalytics.app/script.js';
  script.dataset.site = import.meta.env.VITE_JA_SITE_ID;
  script.dataset.autoPageview = 'true';
  script.async = true;
  document.head.appendChild(script);
}

That's it. Your main bundle stays exactly as small as it was. The tracking script loads after first paint, doesn't block rendering, and weighs under 5KB gzipped. Compare that to importing @sentry/browser — that's 70-90KB in your critical path.

One thing to watch: if your app errors before the script loads, you'll miss it. We'll fix that next.

Step 2: Create a Preact error boundary

Preact supports error boundaries through componentDidCatch, same as React. The difference? Preact's implementation is about 500 bytes. Here's a boundary that captures errors and forwards them to JustAnalytics:

// src/components/ErrorBoundary.tsx
import { Component, ComponentChildren } from 'preact';

interface Props {
  children: ComponentChildren;
  fallback?: ComponentChildren;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: { componentStack?: string }) {
    // Queue the error — script might not be loaded yet
    const payload = {
      event: 'exception',
      properties: {
        type: 'component',
        error_name: error.name,
        error_message: error.message,
        stack_trace: error.stack?.slice(0, 5000),
        component_stack: errorInfo.componentStack?.slice(0, 2000),
        url: window.location.href,
        timestamp: new Date().toISOString(),
      },
    };

    // Use the global tracker if loaded, otherwise queue
    if (window.ja) {
      window.ja('event', 'exception', payload.properties);
    } else {
      // Queue for when script loads
      window.__jaQueue = window.__jaQueue || [];
      window.__jaQueue.push(payload);
    }
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div style={{ padding: '20px', textAlign: 'center' }}>
          <p>Something went wrong. Please refresh the page.</p>
        </div>
      );
    }
    return this.props.children;
  }
}

Add the TypeScript declarations so you don't get red squiggles:

// src/types/global.d.ts
declare global {
  interface Window {
    ja?: (action: string, event: string, properties?: Record<string, unknown>) => void;
    __jaQueue?: Array<{ event: string; properties: Record<string, unknown> }>;
  }
}
export {};

Wrap your app:

// src/App.tsx
import { ErrorBoundary } from './components/ErrorBoundary';
import { MainContent } from './components/MainContent';

export function App() {
  return (
    <ErrorBoundary>
      <MainContent />
    </ErrorBoundary>
  );
}

The queue pattern handles the race condition where your app errors before the tracking script loads. Once the script initializes, it checks for window.__jaQueue and flushes any pending events. (JustAnalytics does this automatically. If you're rolling your own, add an onload handler.)

Step 3: Handle global errors and unhandled rejections

Error boundaries only catch errors during rendering. An async callback that throws? A Promise rejection nobody awaited? Those slip through. Add global handlers:

// src/lib/errorHandlers.ts
export function setupGlobalErrorHandlers() {
  if (typeof window === 'undefined') return;

  // Uncaught exceptions
  window.addEventListener('error', (event) => {
    const payload = {
      type: 'uncaught',
      error_name: event.error?.name || 'Error',
      error_message: event.message,
      stack_trace: event.error?.stack?.slice(0, 5000),
      filename: event.filename,
      lineno: event.lineno,
      colno: event.colno,
      url: window.location.href,
    };

    if (window.ja) {
      window.ja('event', 'exception', payload);
    } else {
      window.__jaQueue = window.__jaQueue || [];
      window.__jaQueue.push({ event: 'exception', properties: payload });
    }
  });

  // Unhandled promise rejections
  window.addEventListener('unhandledrejection', (event) => {
    const error = event.reason;
    const payload = {
      type: 'unhandled_rejection',
      error_name: error?.name || 'UnhandledRejection',
      error_message: error?.message || String(error),
      stack_trace: error?.stack?.slice(0, 5000),
      url: window.location.href,
    };

    if (window.ja) {
      window.ja('event', 'exception', payload);
    } else {
      window.__jaQueue = window.__jaQueue || [];
      window.__jaQueue.push({ event: 'exception', properties: payload });
    }
  });
}

Call it at app startup:

// src/index.tsx
import { render } from 'preact';
import { App } from './App';
import { setupGlobalErrorHandlers } from './lib/errorHandlers';

setupGlobalErrorHandlers();
render(<App />, document.getElementById('app')!);
// ... script loading code from Step 1

Now you're catching component crashes, uncaught exceptions, and unhandled rejections. But there's one more Preact-specific gotcha.

Step 4: Track errors in @preact/signals

Signals are beautiful. I love them. I also lost a weekend debugging a computed that threw in production and left no trace. (Ask me about my Saturday. Actually, don't.)

Here's the problem: @preact/signals runs computeds and effects synchronously in response to signal changes. If a computed throws, it doesn't bubble through Preact's error boundary — it throws wherever the signal was accessed. If an effect throws and nothing catches it, behavior varies by context. Maddening.

Wrap signal effects explicitly:

// src/lib/safeEffect.ts
import { effect } from '@preact/signals';

export function safeEffect(fn: () => void | (() => void), name?: string) {
  return effect(() => {
    try {
      return fn();
    } catch (error) {
      const err = error as Error;
      const payload = {
        type: 'signal_effect',
        effect_name: name || 'anonymous',
        error_name: err.name,
        error_message: err.message,
        stack_trace: err.stack?.slice(0, 5000),
        url: window.location.href,
      };

      if (window.ja) {
        window.ja('event', 'exception', payload);
      } else {
        window.__jaQueue = window.__jaQueue || [];
        window.__jaQueue.push({ event: 'exception', properties: payload });
      }

      // Re-throw so calling code knows something broke
      throw error;
    }
  });
}

Use it instead of raw effect:

import { signal } from '@preact/signals';
import { safeEffect } from './lib/safeEffect';

const count = signal(0);

// Named effects are easier to debug
safeEffect(() => {
  console.log('Count changed:', count.value);
  if (count.value > 100) {
    throw new Error('Count too high'); // Now this gets tracked
  }
}, 'countLogger');

For computeds, you can't wrap them as elegantly — they're supposed to be pure. But you can create a safe computed factory:

// src/lib/safeComputed.ts
import { computed, Signal } from '@preact/signals';

export function safeComputed<T>(fn: () => T, fallback: T, name?: string): Signal<T> {
  return computed(() => {
    try {
      return fn();
    } catch (error) {
      const err = error as Error;

      if (window.ja) {
        window.ja('event', 'exception', {
          type: 'signal_computed',
          computed_name: name || 'anonymous',
          error_name: err.name,
          error_message: err.message,
          stack_trace: err.stack?.slice(0, 5000),
        });
      }

      return fallback;
    }
  });
}

This returns a fallback value instead of crashing. Whether that's appropriate depends on your use case. For UI computeds, a fallback usually beats a blank screen. For business logic, maybe you want the crash. Honestly, I lean toward crashing loud and early — silent failures haunt you later.

Step 5: Upload source maps for readable stack traces

Minified stack traces are useless. at e (app.abc123.js:1:4823) tells you nothing. Upload source maps so errors resolve to actual file names and line numbers.

If you're using Vite:

// vite.config.ts
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';

export default defineConfig({
  plugins: [preact()],
  build: {
    sourcemap: true, // Generate source maps
  },
});

Then upload after build:

# In your CI/CD or build script
vite build

curl -X POST https://api.justanalytics.app/v1/sourcemaps \
  -H "Authorization: Bearer $JUSTANALYTICS_API_KEY" \
  -F "release=$(git rev-parse --short HEAD)" \
  -F "sourcemaps=@dist/assets"

Tag your errors with the same release ID:

// In your error tracking code
properties: {
  // ... other properties
  release: import.meta.env.VITE_GIT_SHA || 'dev',
}

Now when an error hits production, the stack trace shows src/components/Counter.tsx:23 instead of gibberish. Worth every byte of the source map upload. Trust me — you'll thank yourself at 2am. If you're shipping multiple products, check out our observability budgeting guide for seed-stage startups.

Common errors and how to fix them

"window is not defined" during SSR or prerendering. You're running error tracking code on the server. Guard all browser APIs with typeof window !== 'undefined'. This is especially common if you're using preact-iso or preact-render-to-string for static generation.

Errors fire twice — once from error boundary, once from window.onerror. Error boundaries catch errors but don't prevent them from bubbling to window. Check event.error against recent boundary catches and dedupe:

let lastBoundaryError: Error | null = null;

// In ErrorBoundary
componentDidCatch(error: Error) {
  lastBoundaryError = error;
  // ... track
}

// In window error handler
window.addEventListener('error', (event) => {
  if (event.error === lastBoundaryError) return; // Already tracked
  // ... track
});

Source maps not resolving. Either the release ID doesn't match between upload and error events, or your source maps aren't being uploaded correctly. I've made this mistake three times. Check that dist/assets (or wherever your build outputs) actually contains .js.map files. Vite sometimes splits chunks weirdly — you might need to upload from multiple directories.

Signal effects silently swallow errors. @preact/signals doesn't have built-in error handling for effects. Without the safeEffect wrapper from Step 4, errors in effects either crash the app or disappear depending on timing. Always wrap effects that do anything risky.

Next steps

You've got Preact error tracking that respects your bundle budget. Here's where to go from here:

  • Add session replay to see exactly what users did before errors hit — useful when stack traces alone don't explain the bug
  • Set up SLO alerts so you know when error rates spike past acceptable thresholds
  • If you're running multiple Preact widgets across client sites, JustBrowser keeps testing sessions isolated — see how antidetect browsers work with Playwright
  • For teams consolidating their observability stack, we've written about replacing five tools with one — error tracking is just one piece
  • Want to correlate errors with analytics funnel drop-off? That's the real power move.

The whole setup adds maybe 50 lines to your codebase and zero bytes to your main bundle. If you chose Preact for the size, this approach lets you keep that advantage while still knowing when things break in production. Not elegant, but it works. And that's what matters.

Frequently Asked Questions

Does this work with @preact/signals-react for React compatibility mode?

Yes. The signals-react adapter uses the same reactive primitives under the hood, so the error boundary patterns and effect-based tracking work identically. The only difference is you import from @preact/signals-react instead of @preact/signals. If you're running Preact in React compat mode with aliasing, the error tracking code doesn't care — it hooks into the same lifecycle methods.

Will adding error tracking break my sub-10KB bundle target?

Not with this approach. The JustAnalytics script loads asynchronously and weighs under 5KB gzipped. Your main bundle stays untouched. Compare that to Sentry's browser SDK at 70-90KB minified or Datadog RUM at 50KB+. If you're shipping a Preact app specifically to hit aggressive bundle targets, those SDKs defeat the purpose. The async script approach keeps your critical path clean.

How do I track errors inside signal computeds and effects?

Wrap the computed or effect body in a try-catch and call your error tracking function in the catch block. Signals don't have built-in error boundaries like React components, so uncaught errors in effects will crash silently or propagate up to window.onerror depending on the context. Explicit try-catch in each effect is verbose but gives you the most control over error metadata.

Can I use this with Fresh or other Preact-based meta-frameworks?

Fresh (Deno's Preact framework) works with minor adjustments. Fresh islands hydrate independently, so you'll want error tracking in each island's entry point rather than a single root boundary. The server-side error handling differs too — Fresh uses Deno's native error handling. For client-side errors in islands, the patterns in this tutorial apply directly. We're working on a dedicated Fresh guide.


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 ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts