Error Tracking for Electron Desktop Apps: Main, Renderer, and Native Crashes in One View
EngineeringJune 16, 202613 min read

Error Tracking for Electron Desktop Apps: Main, Renderer, and Native Crashes in One View

Catch main, renderer, and native crashes in one dashboard — debug Electron apps faster.

The crash report landed in my inbox at 6:14 PM on a Friday. A user on Windows 11 had hit something that killed the app cold — no error dialog, no graceful shutdown, just gone. The crashpad dump was 47 MB of hex and memory addresses. I spent the weekend reading Chromium source to figure out what 0x00007FF9B2C41A23 meant.

Turns out it was an out-of-memory condition in the renderer process while loading a 200 MB JSON file. The main process never saw it coming. The renderer just... died. And because I was tracking errors in the main process only, my dashboard showed nothing.

Electron apps have this problem that web apps don't: three completely different execution contexts. Main process runs Node.js. Renderer processes run Chromium with V8. And underneath both, native code can crash in ways that neither JavaScript runtime ever sees.

Most error tracking setups catch one, maybe two of these. Users hit the third. You spend your weekend decoding crashpad dumps. Fun.

This tutorial wires up JustAnalytics to capture all three — main process exceptions, renderer errors, and native crashes — in one dashboard. If you're new to JustAnalytics, see our getting started guide first. Plus usage analytics, because knowing that 80% of your users never open the settings panel is actually useful when prioritizing bug fixes.

What we're building

By the end of this, you'll have:

  • Main process error tracking with full stack traces and local variable context
  • Renderer process error capture including uncaught exceptions and unhandled promise rejections
  • Native crash reporting via Crashpad, with symbol file uploads for readable traces
  • Usage analytics (feature adoption, session duration, active users) wired into the same dashboard
  • All of it in one script, one API, one bill

The setup is around 100 lines of code split across main and renderer. Takes maybe thirty minutes from scratch.

Prerequisites

  • Electron 28+ (tested on 30.0.0)
  • Node.js 20+
  • A JustAnalytics account — free tier gives you 100K events/month
  • Basic familiarity with Electron's process model (main vs renderer)
  • For native crash symbolication: your app's .sym or .pdb files from your build

Older Electron version? The concepts still apply but some APIs differ. The crashReporter configuration changed in Electron 14, and the IPC patterns shifted again in 28. I'm writing for current Electron because — honestly — backporting to every version would triple the length of this post and I don't hate myself quite that much. Check the Electron docs if you're stuck on an older release.

Step 1: Main process error tracking

The main process is Node.js with some Electron-specific APIs bolted on. Errors here usually mean the whole app is about to die — uncaught exceptions take down every window.

Create an error handler in your main entry point:

// src/main/error-tracking.js
const { app } = require("electron");

const JA_API_KEY = process.env.JA_API_KEY;
const JA_SITE_ID = process.env.JA_SITE_ID;

async function trackError(error, context = {}) {
  if (!JA_API_KEY) return;

  const payload = {
    site_id: JA_SITE_ID,
    event: "error",
    properties: {
      process: "main",
      message: error.message,
      stack: error.stack,
      name: error.name,
      platform: process.platform,
      arch: process.arch,
      electron_version: process.versions.electron,
      node_version: process.versions.node,
      app_version: app.getVersion(),
      ...context,
    },
  };

  try {
    const response = await fetch("https://api.justanalytics.app/v1/events", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${JA_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    });
    // Fire and forget — don't wait for response in crash scenarios
  } catch (e) {
    // Fail silently. If tracking breaks, don't break the app.
    console.error("Error tracking failed:", e);
  }
}

function setupMainProcessTracking() {
  process.on("uncaughtException", (error) => {
    trackError(error, { type: "uncaughtException" });
    // Re-throw to maintain default behavior (exit)
    // Remove this line if you want to attempt recovery
    throw error;
  });

  process.on("unhandledRejection", (reason) => {
    const error = reason instanceof Error ? reason : new Error(String(reason));
    trackError(error, { type: "unhandledRejection" });
  });
}

module.exports = { setupMainProcessTracking, trackError };

Wire it up in your main entry:

// src/main/index.js
const { app, BrowserWindow } = require("electron");
const { setupMainProcessTracking, trackError } = require("./error-tracking");

setupMainProcessTracking();

app.whenReady().then(() => {
  createWindow();
});

// Example: tracking a caught error with context
async function riskyDatabaseOperation() {
  try {
    await database.query("SELECT * FROM users");
  } catch (error) {
    trackError(error, {
      operation: "database_query",
      query: "SELECT * FROM users",
    });
    throw error; // Re-throw or handle as needed
  }
}

The process.platform and app.getVersion() context is critical.

You'll thank yourself when you're filtering errors by OS version trying to figure out why Windows 10 users see a crash that Windows 11 users don't. (It's almost always a missing DLL or a path separator issue. Almost always. I've debugged maybe a dozen of these and I was wrong exactly once — it was a registry key.)

Step 2: Renderer process error tracking

Renderer processes run in isolated Chromium contexts. Each BrowserWindow has its own. Errors here don't automatically propagate to main — you need to explicitly capture them and send them somewhere.

Create a preload script that wires up error handlers:

// src/preload/error-tracking.js
const { contextBridge, ipcRenderer } = require("electron");

// Expose a safe error reporting API to the renderer
contextBridge.exposeInMainWorld("errorTracking", {
  reportError: (error, context) => {
    ipcRenderer.send("renderer-error", {
      message: error.message,
      stack: error.stack,
      name: error.name,
      ...context,
    });
  },
});

// Capture uncaught errors
window.addEventListener("error", (event) => {
  ipcRenderer.send("renderer-error", {
    message: event.message,
    stack: event.error?.stack,
    name: event.error?.name || "Error",
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    type: "uncaughtError",
  });
});

// Capture unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
  const error = event.reason;
  ipcRenderer.send("renderer-error", {
    message: error?.message || String(error),
    stack: error?.stack,
    name: error?.name || "UnhandledRejection",
    type: "unhandledRejection",
  });
});

Then handle the IPC in main:

// src/main/index.js (add to existing file)
const { ipcMain } = require("electron");

ipcMain.on("renderer-error", (event, errorData) => {
  trackError(
    { message: errorData.message, stack: errorData.stack, name: errorData.name },
    {
      process: "renderer",
      filename: errorData.filename,
      lineno: errorData.lineno,
      colno: errorData.colno,
      type: errorData.type,
    }
  );
});

Make sure your BrowserWindow uses the preload:

const win = new BrowserWindow({
  webPreferences: {
    preload: path.join(__dirname, "../preload/error-tracking.js"),
    contextIsolation: true, // Required for contextBridge
    nodeIntegration: false, // Security best practice
  },
});

One thing I learned the hard way: if you're using multiple renderer processes (multiple windows, webviews, etc.), each one needs the preload attached. Forgetting to add it to a secondary window means errors there disappear into the void. Ask me how I know. (I spent three hours debugging a "phantom crash" that was happening in a print preview window I'd forgotten about.)

Step 3: Native crash reporting with Crashpad

JavaScript errors are the easy part.

Native crashes — V8 running out of memory, a native Node addon segfaulting, Chromium hitting an assertion — bypass JavaScript entirely. The process just dies. No event handlers. No cleanup. Gone.

Electron bundles Crashpad for this. When the native code crashes, Crashpad writes a minidump and can upload it to a server. Configure it in main:

// src/main/crash-reporter.js
const { crashReporter, app } = require("electron");

function setupCrashReporter() {
  crashReporter.start({
    submitURL: "https://api.justanalytics.app/v1/crashes",
    productName: app.getName(),
    companyName: "YourCompany",
    uploadToServer: true,
    ignoreSystemCrashHandler: false,
    extra: {
      site_id: process.env.JA_SITE_ID,
      app_version: app.getVersion(),
      platform: process.platform,
      arch: process.arch,
    },
  });
}

module.exports = { setupCrashReporter };

Call it early in your main process:

// src/main/index.js
const { setupCrashReporter } = require("./crash-reporter");

// Initialize crash reporter before anything else
setupCrashReporter();

// ... rest of your app

The extra object gets attached to every crash report. Include anything that helps you reproduce — user ID (hashed), feature flags, last significant action.

Don't include PII. This should go without saying, but I've reviewed codebases that send full email addresses in crash reports. Full names. Phone numbers. One time, a credit card number. (They had bigger problems than error tracking, but still.)

For readable stack traces, you need to upload symbol files. Native crashes come in as memory addresses; symbols map those back to function names and source lines.

# After building your app, extract symbols
electron-symbols --app dist/MyApp-darwin-x64/MyApp.app \
  --output symbols/

# Upload to JustAnalytics
curl -X POST https://api.justanalytics.app/v1/symbols \
  -H "Authorization: Bearer $JA_API_KEY" \
  -F "site_id=$JA_SITE_ID" \
  -F "version=$(node -p "require('./package.json').version")" \
  -F "symbols=@symbols/MyApp.sym"

Add this to your CI pipeline after the build step. Symbol uploads are per-version — every release needs its own set.

Step 4: Usage analytics alongside errors

Errors without context are half the story. Knowing that the crash happened is useful; knowing that it happened after the user opened a specific feature, during a specific workflow, is actionable.

Add event tracking to your preload:

// src/preload/analytics.js (merge with error-tracking.js or keep separate)
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("analytics", {
  track: (eventName, properties = {}) => {
    ipcRenderer.send("analytics-event", { eventName, properties });
  },
  page: (pageName) => {
    ipcRenderer.send("analytics-event", {
      eventName: "pageview",
      properties: { page: pageName },
    });
  },
});

Handle in main:

// src/main/index.js
ipcMain.on("analytics-event", async (event, { eventName, properties }) => {
  if (!process.env.JA_API_KEY) return;

  await fetch("https://api.justanalytics.app/v1/events", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.JA_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      site_id: process.env.JA_SITE_ID,
      event: eventName,
      properties: {
        ...properties,
        platform: process.platform,
        app_version: app.getVersion(),
      },
    }),
  }).catch(() => {});
});

Use it in your renderer code:

// In your renderer JavaScript
window.analytics.page("settings");

document.getElementById("export-btn").addEventListener("click", () => {
  window.analytics.track("export_started", { format: "csv" });
  // ... do the export
});

Now when a user reports "the app crashes when I export," you can see their event trail: opened settings, changed format to CSV, clicked export, crash. The pattern emerges from the data.

This is where I get opinionated: I think every desktop app should ship with this from day one. Not after launch. Not after the first angry support ticket. Day one. The cost of adding it later — when you're scrambling to debug something — is always higher than the thirty minutes it takes to wire it up at the start. Teams running complex desktop workflows often pair this kind of tracking with DevOS for managing development environments across the engineering team. For browser-based teams, JustBrowser offers similar coordination features.

Common errors and how to fix them

"crashReporter.start: submitURL is required"

You called crashReporter.start() without passing the submitURL option, or it's undefined because the environment variable didn't load. Add a guard: if (!process.env.JA_API_KEY) { console.warn('Crash reporting disabled: no API key'); return; }.

Renderer errors show "undefined" for filename and lineno

Your JavaScript is bundled/minified and you didn't upload source maps. The browser sees bundle.js:1:847329, which isn't helpful. Upload source maps during build — same concept as web apps, different upload endpoint.

Native crashes upload but show hex addresses instead of function names

Symbols aren't uploaded, or the version doesn't match. Crashpad uses the productName and version to look up symbols. If your build produces MyApp 1.2.3 but you uploaded symbols for 1.2.4, the lookup fails silently. Version tagging is critical here.

IPC events from renderer never arrive in main

The preload isn't attached to that window, or contextIsolation is false (which breaks contextBridge). Verify your BrowserWindow config. Also check that you're not accidentally creating the window before the app is ready — some IPC handlers might not be registered yet.

App freezes on startup after adding error tracking

You're probably doing synchronous network calls during app initialization. The fetch calls in the examples are async, but if you wrapped them in await at the top level of your main process and the network is slow, the app hangs. Use fire-and-forget for error reporting: send the request, don't wait for the response.

(Yes, I've made this mistake. In production. On a demo for a client. We sat there for eight seconds staring at a frozen splash screen before I remembered what I'd changed that morning.)

Next steps

You've got unified error tracking across all three Electron process types. A few things worth wiring up next:

Auto-updates with error correlation. Tag your errors with the app version so you can see when a new release introduced a regression. Electron's auto-updater can fire an analytics event on update completion — then you can filter the dashboard by version and spot patterns.

Session replay for renderer issues. JustAnalytics includes session replay that works in Electron's Chromium context. Record what the user did before the crash and debug visually instead of guessing from logs. The session replay privacy guide covers PII masking for sensitive apps.

Uptime monitoring for your backend. If your Electron app talks to an API, monitor that too — sometimes "the app crashed" is really "the API timed out and the app didn't handle it gracefully." JustAnalytics bundles uptime monitoring with the same quota.

The correlation between frontend errors and backend performance often reveals the real issue. We've written about that pattern in the funnel drop-off correlation guide. For desktop apps specifically, the pattern is: user action → API call → timeout → unhandled rejection → crash.

Seeing the whole chain in one dashboard cuts debugging time. Not "dramatically." I hate that word. But measurably. Noticeably. Enough that you'll wonder why you didn't set it up sooner.

If you're building Electron apps alongside web apps (a common pattern for SaaS with a desktop client), you can track both from the same JustAnalytics project. Same dashboard, unified view of errors across platforms. Teams running paid acquisition for their products often pair the setup with ClickzProtect to spot when ad traffic leads to suspiciously high error rates — VeloCalls users tracking call-center desktop apps have found this combination particularly effective.

Frequently Asked Questions

Can I use this for Electron apps built with Electron Forge or Electron Builder?

Yes. The error tracking integration sits in your app code, not in the build tooling. Whether you're using Forge, Builder, or a custom Webpack/Vite setup, the same patterns apply. Just make sure your build process doesn't strip the source maps from the main process bundle — some Builder configs do this by default in production mode.

How does this compare to Sentry's Electron SDK?

Sentry's Electron SDK handles the same three surfaces (main, renderer, native), but it's error tracking only. You'll still need a separate analytics tool for usage data and engagement. JustAnalytics gives you error tracking plus analytics plus APM plus session replay in one under-5KB script — one bill instead of two or three.

Where does Sentry win? If you need their extensive issue tracker integrations, or if you're shipping millions of error events monthly and need their scale. Fair is fair.

Will native crashpad dumps include symbol files?

Only if you upload them. Native crashes from V8 or Chromium internals need .sym files uploaded during your build process to produce readable stack traces. Without symbols, you'll see memory addresses instead of function names. We cover the symbol upload workflow in the tutorial — it adds maybe five minutes to your CI pipeline.

Does this work on Windows, macOS, and Linux?

Yes. The JavaScript-level tracking works identically across platforms. Native crash reporting has slight differences — Windows uses minidumps, macOS and Linux use Breakpad/Crashpad dumps — but JustAnalytics normalizes them into the same format. You'll see crashes tagged by OS in the dashboard for easy filtering.


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