Skip to main content
Console Errors

How to Monitor JavaScript Errors Without Sentry

Lightweight alternatives to Sentry for catching JavaScript errors in production, from browser APIs to self-hosted solutions and Chrome extensions.

Updated

How to Monitor JavaScript Errors Without Sentry

Sentry is the default answer for JavaScript error monitoring. But for smaller projects, solo developers, or early-stage products, it can be more than you need. The free tier has limits, the SDK adds bundle weight, and the dashboard has a learning curve that doesn’t pay off when you’re getting 50 visitors a day.

You have options. The browser already has APIs for catching every type of error. You can send them wherever you want.

Browser APIs for error capture

window.onerror for uncaught exceptions

This is the oldest and most widely supported error handler. It fires for any uncaught runtime error.

window.onerror = function handleError(message, source, lineno, colno, error) {
  const payload = {
    message,
    source,
    lineno,
    colno,
    stack: error?.stack || '',
    url: window.location.href,
    timestamp: new Date().toISOString(),
  }

  navigator.sendBeacon('/api/errors', JSON.stringify(payload))

  return false // still logs the error to the console
}

navigator.sendBeacon is the right choice for error reporting because it doesn’t block page unload. If the user closes the tab right after the error, the report still gets sent.

Limitations: window.onerror does not catch promise rejections or errors inside async functions that aren’t awaited. It also shows "Script error." for cross-origin scripts (see the crossorigin attribute fix).

window.addEventListener('error') for resource failures

The error event on window also captures resource load failures (images, scripts, stylesheets that fail to load). The event listener version gives you more information than onerror.

window.addEventListener('error', (event) => {
  if (event.target !== window) {
    // Resource load error (img, script, link)
    const payload = {
      type: 'resource-error',
      tagName: event.target.tagName,
      src: event.target.src || event.target.href,
      url: window.location.href,
      timestamp: new Date().toISOString(),
    }
    navigator.sendBeacon('/api/errors', JSON.stringify(payload))
  }
}, true) // must use capture phase for resource errors

The true parameter (capture phase) is required. Resource load errors don’t bubble, so the normal event listener phase misses them.

unhandledrejection for promise errors

Any promise that rejects without a .catch() fires this event. In modern JavaScript, this covers most async errors.

window.addEventListener('unhandledrejection', (event) => {
  const error = event.reason
  const payload = {
    type: 'unhandled-rejection',
    message: error?.message || String(error),
    stack: error?.stack || '',
    url: window.location.href,
    timestamp: new Date().toISOString(),
  }

  navigator.sendBeacon('/api/errors', JSON.stringify(payload))
})

ReportingObserver for browser-generated warnings

ReportingObserver captures deprecation warnings, browser interventions, and CSP violations that don’t throw errors but indicate problems.

if (typeof ReportingObserver !== 'undefined') {
  const observer = new ReportingObserver((reports) => {
    for (const report of reports) {
      const payload = {
        type: report.type,
        body: report.body,
        url: window.location.href,
        timestamp: new Date().toISOString(),
      }
      navigator.sendBeacon('/api/errors', JSON.stringify(payload))
    }
  })

  observer.observe()
}

This catches things like document.write being blocked, heavy ads being throttled, and deprecated API usage. Browser support is Chromium-only as of early 2026.

Building a minimal error endpoint

You don’t need a full error tracking platform. A single API endpoint that writes to a database or log file covers the basics.

Cloudflare Worker example

export default {
  async fetch(request, env) {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 })
    }

    const errors = await request.json()
    const payload = Array.isArray(errors) ? errors : [errors]

    // Write to KV, D1, or a logging service
    for (const error of payload) {
      await env.ERROR_LOG.put(
        `${Date.now()}-${crypto.randomUUID()}`,
        JSON.stringify(error)
      )
    }

    return new Response('OK', { status: 200 })
  },
}

Express example

import express from 'express'
import { appendFile } from 'fs/promises'

const app = express()
app.use(express.json())

app.post('/api/errors', async (req, res) => {
  const entry = JSON.stringify({
    ...req.body,
    receivedAt: new Date().toISOString(),
    ip: req.ip,
    userAgent: req.headers['user-agent'],
  })

  await appendFile('errors.jsonl', entry + '\n')
  res.sendStatus(200)
})

A JSONL file (one JSON object per line) is easy to grep, tail, and pipe into other tools. For higher volume, use a proper database.

Rate limiting and deduplication

Without guardrails, a single broken page can send thousands of identical errors per minute.

const errorCounts = new Map()
const MAX_REPORTS_PER_ERROR = 5
const RESET_INTERVAL = 60000

setInterval(() => errorCounts.clear(), RESET_INTERVAL)

function reportError(payload) {
  const key = `${payload.message}:${payload.source}:${payload.lineno}`
  const count = errorCounts.get(key) || 0

  if (count >= MAX_REPORTS_PER_ERROR) return

  errorCounts.set(key, count + 1)
  navigator.sendBeacon('/api/errors', JSON.stringify(payload))
}

This limits each unique error to 5 reports per minute. Adjust the threshold based on your traffic.

Combining all listeners into one snippet

Here’s the full setup in one copy-paste block:

<script>
;(function() {
  const endpoint = '/api/errors'
  const counts = new Map()

  function send(payload) {
    const key = payload.message + payload.source
    const n = counts.get(key) || 0
    if (n >= 5) return
    counts.set(key, n + 1)
    navigator.sendBeacon(endpoint, JSON.stringify(payload))
  }

  setInterval(() => counts.clear(), 60000)

  window.onerror = function(msg, src, line, col, err) {
    send({ type: 'error', message: msg, source: src, lineno: line, colno: col, stack: err?.stack || '', url: location.href, ts: new Date().toISOString() })
    return false
  }

  window.addEventListener('unhandledrejection', function(e) {
    const r = e.reason
    send({ type: 'rejection', message: r?.message || String(r), stack: r?.stack || '', url: location.href, ts: new Date().toISOString() })
  })
})()
</script>

Put this in the <head> before any other scripts. It loads synchronously so it catches errors from scripts that load after it.

When you don’t want any server-side setup

If you’re building a side project, a personal site, or a tool for local use, standing up an error reporting endpoint is overhead you might not want.

Hushbug takes a different approach: it runs as a Chrome extension that monitors console errors, network failures, and uncaught exceptions across every tab. No SDK to install, no endpoint to maintain, no code changes. It catches errors on sites you build and sites you use, surfacing issues you’d otherwise miss unless you had DevTools open at the right moment.

For individual developers who want error awareness without infrastructure, this is the lowest-friction option.

Hushbug monitors JavaScript errors, network failures, and console warnings automatically while you browse. Coming soon to the Chrome Web Store.