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.
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.