The 10 Most Common Unhandled Errors Across 6,200+ TypeScript Repos
By Nark Team
71,140 unhandled error paths across 6,283 TypeScript repositories. We scanned every repo with Nark against Profiles for 160+ npm packages — including axios, prisma, stripe, and redis — checking whether each async call site handles the failure modes that the underlying package is documented to produce.
40% of repositories had at least one violation. The top 10 patterns account for over 90% of all violations found.
#1. fetch response.json() without try-catch
Package: undici (Node.js built-in fetch)
Violations: 12,051 across 1,408 repos (92% of repos using fetch)
Pattern: response-json-parse-error
Most developers using Node.js 18+ don't realize they're using undici. It powers the built-in fetch(). The single most common violation in the entire scan: calling response.json() without a try-catch.
When an upstream service degrades and returns an HTML error page instead of JSON — a load balancer 502, a CDN edge timeout, a WAF block page — response.json() throws a SyntaxError. Developers check response.ok for HTTP status codes but forget that the body parse itself can fail independently.
// 8,428 instances of this exact pattern
const response = await fetch('https://api.example.com/data')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json() // SyntaxError if body isn't valid JSON
// Fixed: handle both the HTTP status and the body parse
const response = await fetch('https://api.example.com/data')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
let data
try {
data = await response.json()
} catch {
const text = await response.clone().text()
throw new Error(`Expected JSON, got: ${text.slice(0, 200)}`)
}
This was the #1 pattern by both total count (8,428 for .json() alone) and repo spread (1,091 repos). Add in response.text() (2,625 violations, 737 repos) and response.arrayBuffer() (673 violations, 339 repos), and undici accounts for 12,051 violations total — more than any other package.
#2. TypeORM queries without error handling
Package: typeorm
Violations: 12,050 across 51 repos (100% of repos using TypeORM)
Pattern: query-sql-syntax-error, save-constraint-violation, findone-query-failed-error
Every single repository using TypeORM had violations. 12,050 of them. The most concentrated violation pattern in the entire scan.
The dominant pattern (10,348 violations) is query() and createQueryBuilder() calls without try-catch. TypeORM throws QueryFailedError for constraint violations, connection failures, and malformed queries. A unique constraint violation on insert becomes an unhandled rejection instead of a 409 Conflict:
// QueryFailedError on duplicate email — crashes the request
await dataSource.getRepository(User).save({
email: input.email,
name: input.name
})
// Catches the specific constraint violation
try {
await dataSource.getRepository(User).save({
email: input.email,
name: input.name
})
} catch (error) {
if (error instanceof QueryFailedError && error.message.includes('duplicate key')) {
return res.status(409).json({ error: 'Email already registered' })
}
throw error
}
100% means this isn't a tendency — it's universal among TypeORM users. The library's API surface is large (repositories, query builders, entity manager, migrations) and every database operation can fail. Projects handle some calls but not others, and no existing linter catches the gaps.
#3. React Query silently swallows refetch errors
Package: @tanstack/react-query
Violations: 7,721 across 184 repos (98% of repos using React Query)
Pattern: stale-query-refetch-error, invalidatequeries-silent-refetch-failure, mutation-optimistic-update-rollback
The most surprising entry. React Query is a data-fetching library with built-in error states — isError, error, onError callbacks. And yet 98% of repos using it have unhandled error patterns.
The dominant issue (2,767 violations, 172 repos): stale query refetches fail silently. When React Query refetches a stale query in the background and the request fails, the error is swallowed by default — the UI continues showing stale data with no indication that the refresh failed. If your queryFn throws on a network error, the user sees data from 30 minutes ago with no error indicator:
// Background refetch failure is silently swallowed
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000
})
// Explicit error handling for background refetches
const { data, error, isError } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000,
retry: 2,
useErrorBoundary: (error) => error.status >= 500,
onError: (error) => {
if (error.status === 401) queryClient.invalidateQueries(['auth'])
}
})
if (isError) return <ErrorBanner error={error} />
The second pattern: invalidateQueries() triggers refetches that can fail, but the promise it returns resolves successfully regardless. The third: optimistic updates in mutations that don't implement the onError rollback, leaving the UI in an inconsistent state when the mutation fails.
Frontend developers are the most likely to assume their data layer "just works." React Query makes the happy path incredibly easy, which makes the error paths easy to skip.
#4. dayjs silently returns "Invalid Date" instead of throwing
Package: dayjs
Violations: 4,879 across 250 repos (98% of repos using dayjs)
Pattern: dayjs-invalid-date
dayjs doesn't throw on bad input. It returns an object that looks valid but renders as "Invalid Date" in the UI. dayjs(null), dayjs(undefined), dayjs('not-a-date') — all return a dayjs object. Calling .format() on it produces the string "Invalid Date" which gets displayed to users, stored in databases, and passed to APIs:
// user.createdAt is null from the database — no error, just "Invalid Date" in the UI
const formatted = dayjs(user.createdAt).format('MMMM D, YYYY')
// formatted === "Invalid Date"
// Validate before formatting
const date = dayjs(user.createdAt)
if (!date.isValid()) {
return 'Date unavailable'
}
return date.format('MMMM D, YYYY')
4,651 instances of the core dayjs-invalid-date pattern. The same silent-failure design exists in moment.js (2,094 more violations, 140 repos). Combined, date parsing libraries account for 6,745 violations — the third largest category after fetch and ORMs.
The failure mode here is different from the others on this list. It's not a thrown exception that crashes the process. It's a silent corruption — bad data flowing through the system undetected until a user sees "Invalid Date" on a dashboard, an invoice, or a calendar event.
#5. axios HTTP errors crash the caller
Package: axios
Violations: 4,132 across 378 repos (92% of repos using axios)
Pattern: error-4xx-5xx
The best-known error handling gap in TypeScript. Axios throws an AxiosError on any non-2xx response by default. A 401 from an expired API key, a 429 rate limit, a 503 from a degraded upstream — all become thrown exceptions that crash the caller if there's no try-catch.
botpress/botpress had 134 unguarded axios calls across 35 integrations. From the Vonage messaging integration:
// 401 (expired creds), 429 (rate limit), or network failure crashes the handler
const response = await axios.post(
'https://api.nexmo.com/v1/messages',
payload,
{ auth: { username: ctx.configuration.apiKey, password: ctx.configuration.apiSecret } }
)
await ack({ tags: { id: response.data.message_uuid } })
try {
const response = await axios.post('https://api.nexmo.com/v1/messages', payload, { auth })
await ack({ tags: { id: response.data.message_uuid } })
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status ?? 'network error'
throw new RuntimeError(`Failed to send Vonage message (${status}): ${error.message}`)
}
throw error
}
92% of repos using axios have at least one unguarded call. The rate hasn't changed since axios was first popular — years of blog posts, documentation updates, and TypeScript adoption haven't moved the needle. The gap persists because no tool in the standard TypeScript toolchain checks for it.
#6. zod .parse() throws instead of returning errors
Package: zod
Violations: 1,808 across 174 repos (73% of repos using zod)
Pattern: parse-validation-error
Zod's parse() throws a ZodError when input doesn't match the schema. In an API route, that means malformed client input triggers a 500 Internal Server Error instead of a 400 Bad Request. The fix is one method name: .safeParse() instead of .parse().
civitai/civitai had approximately 100 unguarded schema.parse() calls across public endpoints, webhook handlers, and moderation tools:
// Malformed query params → ZodError → unhandled 500
export default PublicEndpoint(async function handler(req, res) {
const { userId } = schema.parse(req.query)
// Invalid input → structured 400 with field-level errors
export default PublicEndpoint(async function handler(req, res) {
const result = schema.safeParse(req.query)
if (!result.success) return res.status(400).json({ error: result.error })
const { userId } = result.data
The irony: zod exists to make input validation safe. It succeeds — but only if you use safeParse(). The 73% violation rate (lower than most packages on this list) suggests the ecosystem is partially aware of this pattern. But "partially" still means 174 repositories where bad input from any client on the internet produces a 500.
#7. Fastify route handlers that don't handle async errors
Package: fastify
Violations: 1,398 across 51 repos (98% of repos using Fastify)
Pattern: route-handler-async-error, register-errors-deferred-to-ready
Fastify has good built-in error handling — async route handlers that throw are automatically caught and converted to 500 responses. But two patterns slip through.
First (603 violations): route handlers that do async work without awaiting it. A fire-and-forget fetch() or database write inside a route handler throws after the response is sent, becoming an unhandled rejection that crashes the process on Node.js 15+:
fastify.post('/webhook', async (request, reply) => {
reply.send({ received: true })
// This runs after the response — if it throws, the process crashes
processWebhookPayload(request.body) // not awaited
})
Second (624 violations): plugin registration errors are deferred to .ready() or .listen(). If you don't check the error callback, a plugin that fails to initialize (bad database connection string, missing env var) silently leaves the server in a broken state:
// Plugin failure is silently deferred
fastify.register(require('@fastify/postgres'), { connectionString: process.env.DATABASE_URL })
// If DATABASE_URL is wrong, this doesn't throw here — it fails when .listen() is called
98% of Fastify repos have at least one of these patterns. The framework does a lot of the error handling work for you, which makes the gaps that remain harder to spot.
#8. fs-extra operations on paths that don't exist
Package: fs-extra
Violations: 1,253 across 166 repos (86% of repos using fs-extra)
Pattern: remove-permission-denied, readjson-parse-error, copy-enoent
Filesystem operations fail for reasons that are obvious in hindsight but rarely handled in code: the directory doesn't exist, the process doesn't have write permissions, the JSON file contains a syntax error, the disk is full. fs-extra wraps Node's fs module with promises and convenience methods, but the error surface area is identical.
The dominant pattern (299 violations, 88 repos): fs.remove() or fs.emptyDir() on a path where the process lacks permissions. Build scripts, cleanup tasks, and deployment scripts call these without try-catch, and a single permissions issue crashes the entire pipeline:
// EPERM or EACCES on a locked/protected directory crashes the build
await fs.emptyDir(outputDir)
await fs.copy(sourceDir, outputDir)
try {
await fs.emptyDir(outputDir)
} catch (error) {
if (error.code === 'EPERM' || error.code === 'EACCES') {
console.error(`Cannot clean ${outputDir}: insufficient permissions`)
process.exit(1)
}
throw error
}
await fs.copy(sourceDir, outputDir)
86% is high but not the highest on this list. The reason: many fs-extra operations are used in scripts and CLI tools where a crash is acceptable (the developer sees the error immediately). In server processes and CI pipelines, the same crash is a production incident.
#9. Prisma — 98% of repos have unhandled database errors
Package: @prisma/client
Violations: 828 across 48 repos (98% of repos using Prisma)
Pattern: record-not-found, unique-constraint-violation, connection-error
The highest violation rate for any ORM in the scan. 48 out of 49 repositories using Prisma had at least one database call that propagates an unhandled rejection on failure. The one compliant repo had 3 Prisma calls total — all wrapped in try-catch.
The dominant pattern (394 violations, 23 repos): findUniqueOrThrow() and findFirstOrThrow() without catching the NotFoundError. These methods throw by design when no record matches, but callers treat them as if they return null:
// Throws PrismaClientKnownRequestError if no user matches — crashes the request
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId }
})
The second pattern: unique constraint violations on create() and upsert(). A duplicate email on signup throws PrismaClientKnownRequestError with code P2002. Without a catch, the user sees a 500 instead of "email already taken":
try {
const user = await prisma.user.create({
data: { email: input.email, name: input.name }
})
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return res.status(409).json({ error: 'A user with this email already exists' })
}
throw error
}
Connection pool exhaustion at peak traffic, query timeouts during bulk operations, constraint violations on concurrent writes — Prisma throws structured errors with codes, target fields, and constraint names. All of that metadata is lost when the error bubbles up unhandled.
#10. OpenAI SDK — rate limits and timeouts in AI applications
Package: openai
Violations: 464 across 106 repos (96% of repos using the OpenAI SDK)
Pattern: api-rate-limit, api-timeout, api-authentication-error
96% of repositories using the OpenAI SDK have at least one unguarded inference call. The AI application boom created thousands of TypeScript projects that make LLM calls without handling the three failure modes that the SDK documents: rate limiting (429), request timeouts, and authentication errors.
The production impact is immediate. An OpenAI rate limit during peak traffic crashes every in-flight request instead of queuing or retrying. A timeout on a long-running completion (common with GPT-4 and large context windows) crashes the user's session instead of showing a retry prompt. An expired API key at 2am crashes the entire service instead of returning a meaningful error:
// Rate limit (429), timeout, or auth error crashes the handler
const completion = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: userPrompt }]
})
try {
const completion = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: userPrompt }]
})
} catch (error) {
if (error instanceof OpenAI.RateLimitError) {
return res.status(429).json({ error: 'AI service is busy. Please retry in a moment.' })
}
if (error instanceof OpenAI.APIConnectionError) {
return res.status(503).json({ error: 'AI service temporarily unavailable.' })
}
throw error
}
464 violations is the smallest count on this list, but the sample set is growing fast. The OpenAI SDK was adopted by 110 repositories in our corpus — overwhelmingly in the past 18 months. AI-generated code is a contributing factor: LLMs themselves tend to produce happy-path code without error handling, which means AI-assisted development amplifies the exact patterns this list documents.
The full picture
| Rank | Package | Violations | Repos affected | % of repos using it |
|---|---|---|---|---|
| 1 | undici (fetch) | 12,051 | 1,408 | 92% |
| 2 | typeorm | 12,050 | 51 | 100% |
| 3 | @tanstack/react-query | 7,721 | 184 | 98% |
| 4 | dayjs | 4,879 | 250 | 98% |
| 5 | axios | 4,132 | 378 | 92% |
| 6 | zod | 1,808 | 174 | 73% |
| 7 | fastify | 1,398 | 51 | 98% |
| 8 | fs-extra | 1,253 | 166 | 86% |
| 9 | @prisma/client | 828 | 48 | 98% |
| 10 | openai | 464 | 106 | 96% |
Total across all packages: 71,140 violations in 6,283 repositories.
Notable omissions from the top 10 that still had high counts: moment.js (3,219 violations, same invalid-date pattern as dayjs), mongoose (947, same ORM pattern as TypeORM/Prisma), knex (2,451, raw SQL without try-catch), ioredis (624, network errors on Redis calls), and jsonwebtoken (310, jwt.decode() without signature verification — a security issue, not a crash).
What the data says
These aren't edge cases. They're the default state of TypeScript error handling. The violation rates — 92% for fetch, 98% for Prisma, 100% for TypeORM — say that handling errors is the exception, not the rule.
The pattern is consistent across every package category:
- HTTP clients (fetch, axios): network errors and non-2xx responses go unhandled
- ORMs (Prisma, TypeORM): constraint violations and connection failures crash the caller
- Validation (zod): parse errors become 500s instead of 400s
- Date libraries (dayjs): invalid input silently corrupts data
- Frontend state (React Query): background refetch failures are silently swallowed
- AI SDKs (OpenAI): rate limits and timeouts crash the handler
No linter catches these. ESLint checks syntax and style. TypeScript checks types. Neither checks whether await prisma.user.create() handles a unique constraint violation, or whether response.json() handles a malformed response body. The gap between "compiles and passes type checks" and "handles every documented failure mode" is where 71,140 violations live.
These aren't edge cases. They're the most common failure modes in production TypeScript. Run npx nark on your repo — you probably have at least 3 of these.
npx nark
Open source. AGPL-3.0. github.com/nark-sh/nark