25 min read

The Bug Catalogue

About 170 kinds of bug, graded by what you must imagine to see them, each with the signal that gives it away.


The story

Two candidates review the same 400-line order service.

The first finds four things in ten minutes: a missing await, an unused import, a variable named data2, and an inconsistent error message. All true. None of them is why the company's customers are being charged twice.

The second takes longer to start. They skim the files, list the entry points, and then go hunting with a mental checklist: where's the money? where does it read, wait, then write? who checks that this order belongs to this user? what happens when the payment provider times out? They find six bugs, including the double charge, a cross-tenant data leak and a retry that charges again, and they rank them in that order.

The difference isn't intelligence or experience. The second candidate knew what kinds of bugs exist, so they knew what to look for. This chapter is that list.


How to use this catalogue

  • Skim it once, fully. You're building a mental index. You won't remember every row, but you'll recognise the shape when you see it in code.
  • Use the "Signal" column during reviews. It's what you'll actually see on screen: a pattern, a keyword, a missing check.
  • Check yourself against the "Who" column. J = a junior should catch it, M = an engineer with about three years' experience should catch it, S = senior. The M rows are the core of most interviews.
  • Detail, examples and drills for each level are in the level chapters (Chapters 3–8).

L1 · The line — read it and it's wrong

You don't need context. The line itself is the bug.

Slips

BugWhoSignal
Wrong variable / copy-paste twinJTwo near-identical blocks where one still says start instead of end, or a instead of b
Off-by-oneJi <= arr.length, slice(0, n - 1), ranges that should be half-open
< vs <=JBoundary comparisons on limits, expiries, quotas: "up to 10" vs "fewer than 10"
&& vs || swappedJif (!a || !b) where both must hold; De Morgan mistakes in negations
Inverted conditionJThe if and the else bodies look swapped; a ! too many or too few
= instead of ===JAssignment inside if (…) (TS often catches it, not always)
== coercionJx == 0 true for "" and "0"; null == undefined
Shadowed variableJ/MInner const user hides the outer one; the outer one is never updated
Lost thisMA method passed as a callback (setTimeout(this.tick), arr.map(obj.fn))
switch without breakJCases falling through into the next one
Missing returnJA function that computes a value and doesn't return it; an early-exit check that doesn't return
Missing awaitJ/MAn async call whose result is used as if resolved; a Promise in an if (always truthy)
Unhandled null / undefinedJ.find(…), map.get(…), a DB lookup used without a check

Falsy traps

BugWhoSignal
if (count) rejects 0JTruthiness checks on numbers that can legitimately be 0 (price, quantity, page index)
if (name) rejects ""JTruthiness checks on strings where empty is valid input
x || default overrides valid falsy valuesMlimit || 20 turns limit = 0 into 20; enabled || true is always true. Use ??

Array traps

BugWhoSignal
Arrow with braces returns nothingJmap(x => { x * 2 }) → [undefined, …]
indexOf result used as a booleanJif (arr.indexOf(x)): −1 is truthy and 0 is falsy
find vs filter confusionJExpecting one item, getting an array, or the reverse
splice vs sliceJMutating the original array when a copy was intended
reduce without an initial valueMThrows on an empty array; wrong type on a single element
Array(n).fill({})MAll n slots share one object; editing one edits all
for…in over an arrayMIterates string keys, including inherited ones
return inside forEachJExpecting it to exit the outer function; it only skips one item
Default sort()J/M[10, 9, 1].sort() → [1, 10, 9]; no comparator on numbers; sort mutates in place

Reference traps

BugWhoSignal
=== on objects or DatesJ/MComparing two Dates or two objects with === (compares references)
Shallow copyM{...obj} or Object.assign then mutating a nested field changes the original
Set/includes of objectsMDedupe by object identity: two equal objects are both kept
Mutating inputsMA function that pushes to, sorts or edits the argument it was given
Shared mutable defaultsMA module-level default object handed out and modified by callers

String traps

BugWhoSignal
replace only replaces the first matchJs.replace('-', '') on input with several dashes. Use replaceAll or a /g regex
undefined in templatesJ`Hello ${user.firstName}` printing "Hello undefined"
String + number concatenationJ"1" + 1 === "11"; numbers that arrive as strings (query params, form fields)
Lexical comparison of numbersJ'10' < '9' is true; sorting numeric strings
Case-sensitive comparisonJ/MEmails, usernames, tags compared without normalising case
Untrimmed inputJ" alice@x.com" failing lookups or creating duplicates

Number traps

BugWhoSignal
Floats for moneyJ/Mprice * 1.18, 0.1 + 0.2. Use integer paise/cents or a decimal type
toFixedMReturns a string; rounds 1.005 to "1.00"
Division by zeroJA rate or average with a possibly-zero denominator → Infinity/NaN
Number("") is 0MEmpty input silently becomes zero
parseFloat("1,234") is 1MLocale-formatted numbers parsed naively
NaN propagationMparseInt on bad input, then the NaN flows into arithmetic and comparisons (all false)
Negative moduloM-1 % 7 === -1; day-of-week and ring-buffer index math
IDs beyond 2^53M/S64-bit IDs (Twitter-style, some DBs) parsed into JS numbers lose precision. Keep them as strings

L2 · The function — imagine unusual inputs

The code works for the example in the author's head. Ask what else can arrive.

Edges and branches

BugWhoSignal
Empty inputJarr[0], Math.max(...arr) (−Infinity on []), averages of empty lists
One item, or the maximumJPagination with exactly one page; a batch exactly at the limit
DuplicatesJ/MCode that assumes uniqueness (IDs, emails, tags) without enforcing it
Negative and zero valuesJQuantities, durations, prices, offsets that could be ≤ 0
Very long or very large inputMNo length limit on strings, arrays, files, page sizes
Branch orderJ/MA general case checked before a specific one, so the specific one never runs
Missing default caseJswitch/if chains that silently do nothing for a new status
Pagination mathJ/MMath.floor vs Math.ceil for page count; the last partial page dropped

State

BugWhoSignal
Illegal state transitionsMrefund() works on a pending order; ship() on a cancelled one. Transitions not checked against the current state
Stale derived stateMA cached total, count or flag not updated when its source changes
Half-updated state on errorMSeveral fields mutated, then something throws partway through

Time

BugWhoSignal
UTC vs local parsingMnew Date('2024-01-01') is UTC; new Date('2024-01-01T00:00') is local
0-indexed monthsJnew Date(2024, 1, 1) is February
Month overflowMsetMonth(m + 1) on Jan 31 → March 2 or 3
DSTM/SAdding 24 * 60 * 60 * 1000 for "tomorrow"; days of 23 or 25 hours; jobs at 02:30 local
"End of month", age, "same day"MCalendar math done with milliseconds; time zone of "today" not specified
Non-ISO date strings compared as stringsM'1/12/2024' < '12/1/2023'
Date.now() / new Date() inside logicMUntestable, and inconsistent within one operation. Inject the clock

Text and regex

BugWhoSignal
Length in code unitsM'👍'.length === 2; truncating emoji or names mid-character
Locale case rulesStoUpperCase/toLowerCase for identifiers in some locales (Turkish i)
Regex without anchorsM/\d+/.test(x) accepts "abc123"; validation needs ^…$
Unescaped regex charactersM. matching anything in a domain check; building a regex from user input
Greedy matchingM.* swallowing more than intended

Errors

BugWhoSignal
Swallowed errorJcatch {} or catch (e) { console.log(e) } and carry on as if it worked
Over-broad catchMOne try around ten lines, so a programming error becomes "not found"
Throwing stringsJthrow 'failed': no stack, no type
Lost causeMthrow new Error('failed') inside a catch without { cause: e }
Retrying non-retryable errorsMRetrying 400s, validation errors or "insufficient funds"
return in finallyMOverrides the try's return value or swallows its exception
return promise inside tryMWithout await, the rejection escapes the surrounding catch
Log and continueMAn error is logged, then the code proceeds with a half-built object
Double responseMExpress: sending a response, then falling through and sending another ("headers already sent")

Resources

BugWhoSignal
Leaked timers and listenersMsetInterval / addEventListener / .on(…) with no matching clear/remove
Connection not released on the error pathMpool.connect() then client.release() only on success → pool exhaustion
Unclosed streams and filesMStreams not destroyed on error; file handles kept open

TypeScript holes

BugWhoSignal
anyJ/Many on inputs, especially request bodies and third-party responses
as castsMas User on data that was never validated
Non-null !Muser!.id where user really can be missing
@ts-ignore / @ts-expect-errorMSuppressions near logic, not just types
Unvalidated JSON.parseMTyped as a domain object, but nothing checks the shape
Non-exhaustive unionsMA switch on a union without a never check, so new members are silently ignored
Optional chaining hiding bugsMorder?.items?.length ?? 0 turning "the order is missing" into "zero items"
Unchecked index accessMRecord<string, T> lookups typed as always defined (no noUncheckedIndexedAccess)
Partial<T> updatesMSpreading a Partial into an update writes undefined over real values
Dates after JSONMTyped Date, but at runtime it's a string after a fetch or cache round trip
Lying type guardsM/SisUser(x): x is User that checks one field
Schema and type driftMA Zod schema and a hand-written interface that no longer match
Object.keys is string[]MCasting it to (keyof T)[] and trusting it

Config and environment

BugWhoSignal
Env vars are stringsMif (process.env.FEATURE) is true for "false"
Missing env var → NaNMparseInt(process.env.TIMEOUT) → NaN → setTimeout(fn, NaN) fires immediately
String arithmetic from configMprocess.env.PORT + 1 → "30001"

L3 · The data's journey — follow it across files and the database

Each function looks fine. The bug is in how they connect.

SQL and the database

BugWhoSignal
No transaction across multi-step writesMInsert order, then insert items, then charge, as separate statements
Uniqueness checked only in the appMSELECT … then INSERT with no unique constraint
N+1 queriesMA query inside a loop over the results of another query
OFFSET pagination driftMRows skipped or repeated when data changes between pages; slow deep pages
ORDER BY without a tiebreakerMPaging on created_at alone; equal timestamps shuffle between pages
NULL logicMWHERE x = NULL; NOT IN (subquery) returning nothing when the subquery has a NULL
timestamp vs timestamptzMTime zone silently dropped; comparisons off by the server's offset
Integer divisionMcompleted / total in SQL is 0 for integers
Missing indexMFiltering or joining on a column (often a foreign key) with no index
Case-sensitive unique emailMFoo@x.com and foo@x.com become two accounts
Implicit castsM/SComparing a text column to a number; a cast that prevents index use

Tenancy and deletes

BugWhoSignal
Query missing tenant_idMOne query among many that forgot the tenant filter: a cross-tenant data leak
Soft-deleted rows reappearMdeleted_at IS NULL missing from one query, a join, or a count
Unexpected cascadeM/SON DELETE CASCADE deleting far more than intended
Orphaned rowsMParent and children created in separate non-transactional steps
Drifting denormalised countersMcomment_count incremented in one path, not decremented in another

Money

BugWhoSignal
Rounding direction and placeMRounding per line vs on the total; Math.round on negatives
Splitting amountsM₹100 / 3 = 33.33 × 3 = 99.99: a paisa lost. Distribute the remainder
Mixed currenciesMSumming amounts without checking currency
Negative quantitiesMA quantity of −2 turns a purchase into a refund
Discount stacking and reuseMCoupons combinable when they shouldn't be; one coupon applied twice

Contracts between parts

BugWhoSignal
Unit mismatchesJ/MMilliseconds vs seconds; paise vs rupees; MB vs MiB
Renamed or optional fieldsMThe client still sends userId, the server now reads ownerId
PATCH overwriting with undefinedMSpreading the request body into the update
No validation at the boundaryMRequest bodies, query params, webhooks and queue messages trusted as typed
SerialisationMDate → string, BigInt throws in JSON.stringify, decimals become floats
fetch doesn't reject on 4xx/5xxMNo res.ok check: an error page parsed as data
res.json() on empty bodiesM204 responses or empty bodies throwing in the parser
GET with side effectsMA GET that changes state (prefetchers and crawlers will call it)
Errors returned as 200M{ error: '…' } with status 200, so clients and monitors think it worked

Caching

BugWhoSignal
Key missing a dimensionMCache key without user, tenant, locale or permissions: one user sees another's data
Caching errors or "not found" foreverMA failure or a 404 stored with the normal TTL
TTL in the wrong unitJ/Mttl: 60 meaning seconds in one library, milliseconds in another
Memoising on object identityMA new object every call means the memo never hits, or a mutated object means a stale hit
No invalidation on updateMWrites don't delete or update the cached value

L4 · Two things at once — imagine interleavings and time

Each request is correct alone. Two at once are not. Depth in JS Code Review.

BugWhoSignal
Read → await → writeMRead a value, await something, write based on the old read
Check-then-act (TOCTOU)M"If not exists, create"; "if balance ≥ amount, deduct" in separate steps
Lost updateMTwo writers both save a full object; the second overwrites the first
Double submitMA button or endpoint that runs twice on a double click or a retry
Missing idempotencyMPayments, emails or orders created again when a request is retried
Per-request data in module scopeMlet currentUser at module level in a server
forEach(async …)MThe loop finishes before the work; errors become unhandled
Floating promisesMAn async call with no await, .catch or void marker
Promise.all fails fastMOne rejection loses all the other results; use allSettled when partial success matters
Sequential await in a loopMIndependent calls made one at a time (slow)
Unbounded parallelismMPromise.all(items.map(callApi)) on 10,000 items
Retries without backoff or jitterMImmediate, synchronised retries: a retry storm
Retrying non-idempotent callsMRetrying a POST that charges or sends
Missing timeoutsMAny network call without one
Promise.race "timeout"M/SThe race resolves, but the losing work keeps running and may still write
Boolean flag as a mutexMif (busy) return; busy = true; await … (fine for one caller, wrong across processes)
Lock-order deadlockSTwo code paths taking the same locks in different orders
Cache stampedeM/SA hot key expires and every request rebuilds it at once
Assumed event orderM/SCode that assumes "created" arrives before "updated"
Duplicate deliveriesMQueue or webhook consumers without dedupe
Out-of-order responsesMThe response to an older request overwrites a newer one
Unhandled rejectionsMA rejected promise nobody listens to (crashes Node by default)
'error' event without a listenerMAn EventEmitter/stream emitting 'error' crashes the process
Work after the response in serverlessM/SFire-and-forget after res.send(); the runtime freezes or kills it
Webhook replayMNo timestamp or ID check on signed webhooks
Clock skewSComparing timestamps from different machines; expiry checks across servers

L5 · An attacker — imagine a hostile user

The code works for honest users. Ask what a dishonest one would send.

Authorization

BugWhoSignal
Missing ownership check (IDOR)MGET /orders/:id loads by ID without checking the order belongs to the caller
Checks only in the UIJ/MThe button is hidden for non-admins, but the API doesn't check
Role checked, tenant notMAn admin of company A can act on company B's data
Mass assignmentMdb.users.update(id, req.body) lets users set role, isAdmin, balance

Injection

BugWhoSignal
SQL injectionJ/M`SELECT … WHERE id = ${id}` or string concatenation into queries
ORDER BY / column names from inputMSort fields can't be parameterised. They need an allow-list
NoSQL injectionMPassing req.body filters to Mongo ({ $ne: null })
Command injectionMexec(`convert ${file}`). Use execFile with an argument array
Path traversalMpath.join(uploads, req.params.name) with ../../etc/passwd
eval / new FunctionJ/MExecuting anything built from input
Log injectionM/SNewlines in user input forging log lines
ReDoSSNested quantifiers like (a+)+ on user input
Prototype pollutionM/SDeep-merging user JSON into objects (__proto__)

Browser

BugWhoSignal
XSSMinnerHTML, dangerouslySetInnerHTML, unescaped templates, javascript: URLs
CSRFMCookie-authenticated state changes with no SameSite or token
Open redirectMres.redirect(req.query.next)
CORS reflecting any origin with credentialsM/SAccess-Control-Allow-Origin: <request origin> plus Allow-Credentials: true
Weak cookie flagsMSession cookies without HttpOnly, Secure, SameSite
Tokens in localStorageMAny XSS can read and exfiltrate them

Tokens and secrets

BugWhoSignal
JWT not verifiedMjwt.decode used where jwt.verify was needed
JWT without expiry or with algorithm confusionM/SNo exp; accepting whatever alg the header says
Predictable tokensMMath.random() for reset tokens, invite codes, session IDs. Use crypto.randomUUID / randomBytes
Non-constant-time comparisonS=== on secrets and signatures. Use timingSafeEqual
Reset tokens reusable or never expireMNo expiry and no single-use marking
Secrets in logs or the client bundleMLogging request headers; a server key in a NEXT_PUBLIC_ variable
Secrets committedJ/MKeys in the repo, .env committed, keys in test fixtures

Abuse

BugWhoSignal
No rate limit on login, OTP, resetMUnlimited guesses
Rate limit by X-Forwarded-ForM/STrusting a header any client can set
No input size limitsMUnbounded bodies, uploads, array lengths: memory exhaustion
Unchecked uploadsMType from the client's Content-Type; no size cap; user-chosen filenames
SSRFM/SThe server fetches a URL the user supplies (webhooks, image proxies, "import from URL")
User enumerationM"No account with that email" vs "wrong password"
Leaky errorsJ/MStack traces, SQL or internal hostnames in API errors

L6 · Production — imagine scale, failure, deploys and months of uptime

The code passes every test. It breaks when reality arrives. This is the "breaks easily" family.

Fragility

BugWhoSignal
No timeoutsMAny fetch, DB or SDK call without one
Unbounded growthMCaches, arrays, maps, queues or listener lists that only ever grow
Queries without LIMITMList endpoints that return every row
Assumed input size or shapeM"There will never be more than 100 items"; a fixed number of address lines
Hardcoded URLs, paths, magic numbersJ/Mlocalhost, /Users/…, 3000, 86400 sprinkled through the code
Server time zone dependenceMnew Date().getHours() on a server whose zone isn't fixed
Brittle parsing of third-party payloadsMAssuming every field of a webhook is present and typed
Relying on undocumented behaviourM/SObject key order, a library's internal format, an API's default sort

Lifecycle

BugWhoSignal
No graceful shutdownMNo SIGTERM handling: in-flight requests and jobs killed on every deploy
Startup-order dependenceMCrashes if the DB is slower to start than the app
Retry everything / retry nothingMNo distinction between transient and permanent failures
Unsafe flag defaultsMA feature flag that defaults to on when the flag service is unreachable

Deploy ordering

BugWhoSignal
Code needs schema that isn't there yetM/SNew code reads a column the migration hasn't added yet, or old code breaks on a dropped column
Locking migrationsSAdding a column with a default or an index without CONCURRENTLY on a big table
Breaking old clients or in-flight jobsM/SRenamed fields, changed message formats, removed endpoints
Dev settings in productionJ/M.env loading, debug flags or permissive CORS reaching prod

Performance as an outage

BugWhoSignal
Accidental O(n²)Mincludes/find/indexOf inside a loop over the same data size
Blocking the event loopMreadFileSync, pbkdf2Sync, giant JSON.parse or sync compression in a request handler
Whole tables in memoryMLoading every row to filter or count in JavaScript
Work repeated in loopsMRegexes compiled, config parsed, or connections opened inside a hot loop
Huge logsMJSON.stringify of large objects on every request

Operability

BugWhoSignal
PII or secrets in logsMLogging full request bodies, emails, tokens
Logs with no contextMconsole.log('failed'): no ID, no input, no cause
No correlation IDsM/SCan't follow one request across services
Errors that never alertMSwallowed errors mean nothing reaches monitoring
Global state that makes tests flakyMModule-level state shared between tests and requests

Cross-cutting families

Bugs in the tests

Reviewers are expected to read the tests too. A test that can't fail is worse than no test, because it gives false confidence.

BugWhoSignal
Assertion on an un-awaited promiseMexpect(fn()).rejects… without await; the test passes before it checks
No assertion at allJA test that only calls the function
Mocking the thing under testMThe mock returns the expected value, so the test tests the mock
Time- or order-dependent testsMReal Date.now(), real timers, tests that pass only in a certain order
Shared state between testsMModule-level state or DB rows leaking from one test to the next
Blindly updated snapshotsMHuge snapshots nobody reads
Happy path only on risky codeMMoney, auth and retries tested only with valid input

Library and API traps

BugWhoSignal
fetch vs axios error semanticsMfetch resolves on 500; axios throws on 4xx. Code written for one, used with the other
Mutable date librariesMMoment-style date.add() mutating a shared date
lodash merge on user inputM/SPrototype pollution through a deep merge
Sync vs async variantsMbcrypt.hashSync in a request handler; too low or too high a cost factor
Misread defaultsMNo default timeout in many HTTP clients; retries on by default in some SDKs; pool sizes

Smells that hide bugs

Not bugs themselves. Report them as risks: they're where the next bug will come from.

SmellWhy it matters
Logic duplicated in two placesThe copies drift: two different price calculations
Names that liegetUser() that creates a user; isValid that also saves
Comments that disagree with the codeOne of them is wrong. Find out which
Boolean parameterssend(user, true, false): easy to swap
God functions300-line handlers where every change risks everything
Dead codePaths nobody tests, still reachable by some input

Frontend (catalogue only, drills later)

BugWhoSignal
Stale closuresMAn effect or callback reading an old value of state
Wrong effect dependenciesMMissing deps (stale data), or an object dep (infinite loop)
Fetch racesMAn older response overwrites a newer one; no AbortController
key={index}MReordered or deleted list items keep the wrong state
Controlled ↔ uncontrolled switchJ/Mvalue={x} where x starts undefined
Leaked listeners and controllersMSubscriptions without cleanup in the effect
Hydration mismatchMDate.now(), Math.random(), window used during render
Infinite re-rendersJ/MSetting state during render; new object deps
Double-submitted formsJ/MNo disabled state or idempotency key
Optimistic updates not rolled backMUI shows success after the server rejected
JSON.parse(localStorage…) crashingMCorrupt or old-format stored data

Where to look first: bug hot spots

Bugs cluster. In any unfamiliar codebase, these areas repay the first look:


Ranking what you find

Finding ten bugs and presenting them in random order is weaker than finding six and ranking them well. For each finding, weigh three things:

FactorQuestion
SeverityWhat's the worst outcome: lost money, leaked data, an outage, a wrong number, a typo?
LikelihoodDoes it happen on every request, under load, or only with a hostile user?
Blast radiusOne user, one tenant, or everyone? Recoverable, or permanent?

Then present: security and money first, data corruption next, outages next, wrong results next, everything else last. JS · Reviewing a Codebase covers how to phrase each finding in four parts.


Go deeper