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
| Bug | Who | Signal |
|---|---|---|
| Wrong variable / copy-paste twin | J | Two near-identical blocks where one still says start instead of end, or a instead of b |
| Off-by-one | J | i <= arr.length, slice(0, n - 1), ranges that should be half-open |
< vs <= | J | Boundary comparisons on limits, expiries, quotas: "up to 10" vs "fewer than 10" |
&& vs || swapped | J | if (!a || !b) where both must hold; De Morgan mistakes in negations |
| Inverted condition | J | The if and the else bodies look swapped; a ! too many or too few |
= instead of === | J | Assignment inside if (…) (TS often catches it, not always) |
== coercion | J | x == 0 true for "" and "0"; null == undefined |
| Shadowed variable | J/M | Inner const user hides the outer one; the outer one is never updated |
Lost this | M | A method passed as a callback (setTimeout(this.tick), arr.map(obj.fn)) |
switch without break | J | Cases falling through into the next one |
Missing return | J | A function that computes a value and doesn't return it; an early-exit check that doesn't return |
Missing await | J/M | An async call whose result is used as if resolved; a Promise in an if (always truthy) |
Unhandled null / undefined | J | .find(…), map.get(…), a DB lookup used without a check |
Falsy traps
| Bug | Who | Signal |
|---|---|---|
if (count) rejects 0 | J | Truthiness checks on numbers that can legitimately be 0 (price, quantity, page index) |
if (name) rejects "" | J | Truthiness checks on strings where empty is valid input |
x || default overrides valid falsy values | M | limit || 20 turns limit = 0 into 20; enabled || true is always true. Use ?? |
Array traps
| Bug | Who | Signal |
|---|---|---|
| Arrow with braces returns nothing | J | map(x => { x * 2 }) → [undefined, …] |
indexOf result used as a boolean | J | if (arr.indexOf(x)): −1 is truthy and 0 is falsy |
find vs filter confusion | J | Expecting one item, getting an array, or the reverse |
splice vs slice | J | Mutating the original array when a copy was intended |
reduce without an initial value | M | Throws on an empty array; wrong type on a single element |
Array(n).fill({}) | M | All n slots share one object; editing one edits all |
for…in over an array | M | Iterates string keys, including inherited ones |
return inside forEach | J | Expecting 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
| Bug | Who | Signal |
|---|---|---|
=== on objects or Dates | J/M | Comparing two Dates or two objects with === (compares references) |
| Shallow copy | M | {...obj} or Object.assign then mutating a nested field changes the original |
Set/includes of objects | M | Dedupe by object identity: two equal objects are both kept |
| Mutating inputs | M | A function that pushes to, sorts or edits the argument it was given |
| Shared mutable defaults | M | A module-level default object handed out and modified by callers |
String traps
| Bug | Who | Signal |
|---|---|---|
replace only replaces the first match | J | s.replace('-', '') on input with several dashes. Use replaceAll or a /g regex |
undefined in templates | J | `Hello ${user.firstName}` printing "Hello undefined" |
| String + number concatenation | J | "1" + 1 === "11"; numbers that arrive as strings (query params, form fields) |
| Lexical comparison of numbers | J | '10' < '9' is true; sorting numeric strings |
| Case-sensitive comparison | J/M | Emails, usernames, tags compared without normalising case |
| Untrimmed input | J | " alice@x.com" failing lookups or creating duplicates |
Number traps
| Bug | Who | Signal |
|---|---|---|
| Floats for money | J/M | price * 1.18, 0.1 + 0.2. Use integer paise/cents or a decimal type |
toFixed | M | Returns a string; rounds 1.005 to "1.00" |
| Division by zero | J | A rate or average with a possibly-zero denominator → Infinity/NaN |
Number("") is 0 | M | Empty input silently becomes zero |
parseFloat("1,234") is 1 | M | Locale-formatted numbers parsed naively |
NaN propagation | M | parseInt on bad input, then the NaN flows into arithmetic and comparisons (all false) |
| Negative modulo | M | -1 % 7 === -1; day-of-week and ring-buffer index math |
| IDs beyond 2^53 | M/S | 64-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
| Bug | Who | Signal |
|---|---|---|
| Empty input | J | arr[0], Math.max(...arr) (−Infinity on []), averages of empty lists |
| One item, or the maximum | J | Pagination with exactly one page; a batch exactly at the limit |
| Duplicates | J/M | Code that assumes uniqueness (IDs, emails, tags) without enforcing it |
| Negative and zero values | J | Quantities, durations, prices, offsets that could be ≤ 0 |
| Very long or very large input | M | No length limit on strings, arrays, files, page sizes |
| Branch order | J/M | A general case checked before a specific one, so the specific one never runs |
| Missing default case | J | switch/if chains that silently do nothing for a new status |
| Pagination math | J/M | Math.floor vs Math.ceil for page count; the last partial page dropped |
State
| Bug | Who | Signal |
|---|---|---|
| Illegal state transitions | M | refund() works on a pending order; ship() on a cancelled one. Transitions not checked against the current state |
| Stale derived state | M | A cached total, count or flag not updated when its source changes |
| Half-updated state on error | M | Several fields mutated, then something throws partway through |
Time
| Bug | Who | Signal |
|---|---|---|
| UTC vs local parsing | M | new Date('2024-01-01') is UTC; new Date('2024-01-01T00:00') is local |
| 0-indexed months | J | new Date(2024, 1, 1) is February |
| Month overflow | M | setMonth(m + 1) on Jan 31 → March 2 or 3 |
| DST | M/S | Adding 24 * 60 * 60 * 1000 for "tomorrow"; days of 23 or 25 hours; jobs at 02:30 local |
| "End of month", age, "same day" | M | Calendar math done with milliseconds; time zone of "today" not specified |
| Non-ISO date strings compared as strings | M | '1/12/2024' < '12/1/2023' |
Date.now() / new Date() inside logic | M | Untestable, and inconsistent within one operation. Inject the clock |
Text and regex
| Bug | Who | Signal |
|---|---|---|
| Length in code units | M | '👍'.length === 2; truncating emoji or names mid-character |
| Locale case rules | S | toUpperCase/toLowerCase for identifiers in some locales (Turkish i) |
| Regex without anchors | M | /\d+/.test(x) accepts "abc123"; validation needs ^…$ |
| Unescaped regex characters | M | . matching anything in a domain check; building a regex from user input |
| Greedy matching | M | .* swallowing more than intended |
Errors
| Bug | Who | Signal |
|---|---|---|
| Swallowed error | J | catch {} or catch (e) { console.log(e) } and carry on as if it worked |
Over-broad catch | M | One try around ten lines, so a programming error becomes "not found" |
| Throwing strings | J | throw 'failed': no stack, no type |
| Lost cause | M | throw new Error('failed') inside a catch without { cause: e } |
| Retrying non-retryable errors | M | Retrying 400s, validation errors or "insufficient funds" |
return in finally | M | Overrides the try's return value or swallows its exception |
return promise inside try | M | Without await, the rejection escapes the surrounding catch |
| Log and continue | M | An error is logged, then the code proceeds with a half-built object |
| Double response | M | Express: sending a response, then falling through and sending another ("headers already sent") |
Resources
| Bug | Who | Signal |
|---|---|---|
| Leaked timers and listeners | M | setInterval / addEventListener / .on(…) with no matching clear/remove |
| Connection not released on the error path | M | pool.connect() then client.release() only on success → pool exhaustion |
| Unclosed streams and files | M | Streams not destroyed on error; file handles kept open |
TypeScript holes
| Bug | Who | Signal |
|---|---|---|
any | J/M | any on inputs, especially request bodies and third-party responses |
as casts | M | as User on data that was never validated |
Non-null ! | M | user!.id where user really can be missing |
@ts-ignore / @ts-expect-error | M | Suppressions near logic, not just types |
Unvalidated JSON.parse | M | Typed as a domain object, but nothing checks the shape |
| Non-exhaustive unions | M | A switch on a union without a never check, so new members are silently ignored |
| Optional chaining hiding bugs | M | order?.items?.length ?? 0 turning "the order is missing" into "zero items" |
| Unchecked index access | M | Record<string, T> lookups typed as always defined (no noUncheckedIndexedAccess) |
Partial<T> updates | M | Spreading a Partial into an update writes undefined over real values |
| Dates after JSON | M | Typed Date, but at runtime it's a string after a fetch or cache round trip |
| Lying type guards | M/S | isUser(x): x is User that checks one field |
| Schema and type drift | M | A Zod schema and a hand-written interface that no longer match |
Object.keys is string[] | M | Casting it to (keyof T)[] and trusting it |
Config and environment
| Bug | Who | Signal |
|---|---|---|
| Env vars are strings | M | if (process.env.FEATURE) is true for "false" |
Missing env var → NaN | M | parseInt(process.env.TIMEOUT) → NaN → setTimeout(fn, NaN) fires immediately |
| String arithmetic from config | M | process.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
| Bug | Who | Signal |
|---|---|---|
| No transaction across multi-step writes | M | Insert order, then insert items, then charge, as separate statements |
| Uniqueness checked only in the app | M | SELECT … then INSERT with no unique constraint |
| N+1 queries | M | A query inside a loop over the results of another query |
OFFSET pagination drift | M | Rows skipped or repeated when data changes between pages; slow deep pages |
ORDER BY without a tiebreaker | M | Paging on created_at alone; equal timestamps shuffle between pages |
NULL logic | M | WHERE x = NULL; NOT IN (subquery) returning nothing when the subquery has a NULL |
timestamp vs timestamptz | M | Time zone silently dropped; comparisons off by the server's offset |
| Integer division | M | completed / total in SQL is 0 for integers |
| Missing index | M | Filtering or joining on a column (often a foreign key) with no index |
| Case-sensitive unique email | M | Foo@x.com and foo@x.com become two accounts |
| Implicit casts | M/S | Comparing a text column to a number; a cast that prevents index use |
Tenancy and deletes
| Bug | Who | Signal |
|---|---|---|
Query missing tenant_id | M | One query among many that forgot the tenant filter: a cross-tenant data leak |
| Soft-deleted rows reappear | M | deleted_at IS NULL missing from one query, a join, or a count |
| Unexpected cascade | M/S | ON DELETE CASCADE deleting far more than intended |
| Orphaned rows | M | Parent and children created in separate non-transactional steps |
| Drifting denormalised counters | M | comment_count incremented in one path, not decremented in another |
Money
| Bug | Who | Signal |
|---|---|---|
| Rounding direction and place | M | Rounding per line vs on the total; Math.round on negatives |
| Splitting amounts | M | ₹100 / 3 = 33.33 × 3 = 99.99: a paisa lost. Distribute the remainder |
| Mixed currencies | M | Summing amounts without checking currency |
| Negative quantities | M | A quantity of −2 turns a purchase into a refund |
| Discount stacking and reuse | M | Coupons combinable when they shouldn't be; one coupon applied twice |
Contracts between parts
| Bug | Who | Signal |
|---|---|---|
| Unit mismatches | J/M | Milliseconds vs seconds; paise vs rupees; MB vs MiB |
| Renamed or optional fields | M | The client still sends userId, the server now reads ownerId |
PATCH overwriting with undefined | M | Spreading the request body into the update |
| No validation at the boundary | M | Request bodies, query params, webhooks and queue messages trusted as typed |
| Serialisation | M | Date → string, BigInt throws in JSON.stringify, decimals become floats |
fetch doesn't reject on 4xx/5xx | M | No res.ok check: an error page parsed as data |
res.json() on empty bodies | M | 204 responses or empty bodies throwing in the parser |
| GET with side effects | M | A GET that changes state (prefetchers and crawlers will call it) |
| Errors returned as 200 | M | { error: '…' } with status 200, so clients and monitors think it worked |
Caching
| Bug | Who | Signal |
|---|---|---|
| Key missing a dimension | M | Cache key without user, tenant, locale or permissions: one user sees another's data |
| Caching errors or "not found" forever | M | A failure or a 404 stored with the normal TTL |
| TTL in the wrong unit | J/M | ttl: 60 meaning seconds in one library, milliseconds in another |
| Memoising on object identity | M | A new object every call means the memo never hits, or a mutated object means a stale hit |
| No invalidation on update | M | Writes 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.
| Bug | Who | Signal |
|---|---|---|
Read → await → write | M | Read 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 update | M | Two writers both save a full object; the second overwrites the first |
| Double submit | M | A button or endpoint that runs twice on a double click or a retry |
| Missing idempotency | M | Payments, emails or orders created again when a request is retried |
| Per-request data in module scope | M | let currentUser at module level in a server |
forEach(async …) | M | The loop finishes before the work; errors become unhandled |
| Floating promises | M | An async call with no await, .catch or void marker |
Promise.all fails fast | M | One rejection loses all the other results; use allSettled when partial success matters |
Sequential await in a loop | M | Independent calls made one at a time (slow) |
| Unbounded parallelism | M | Promise.all(items.map(callApi)) on 10,000 items |
| Retries without backoff or jitter | M | Immediate, synchronised retries: a retry storm |
| Retrying non-idempotent calls | M | Retrying a POST that charges or sends |
| Missing timeouts | M | Any network call without one |
Promise.race "timeout" | M/S | The race resolves, but the losing work keeps running and may still write |
| Boolean flag as a mutex | M | if (busy) return; busy = true; await … (fine for one caller, wrong across processes) |
| Lock-order deadlock | S | Two code paths taking the same locks in different orders |
| Cache stampede | M/S | A hot key expires and every request rebuilds it at once |
| Assumed event order | M/S | Code that assumes "created" arrives before "updated" |
| Duplicate deliveries | M | Queue or webhook consumers without dedupe |
| Out-of-order responses | M | The response to an older request overwrites a newer one |
| Unhandled rejections | M | A rejected promise nobody listens to (crashes Node by default) |
'error' event without a listener | M | An EventEmitter/stream emitting 'error' crashes the process |
| Work after the response in serverless | M/S | Fire-and-forget after res.send(); the runtime freezes or kills it |
| Webhook replay | M | No timestamp or ID check on signed webhooks |
| Clock skew | S | Comparing 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
| Bug | Who | Signal |
|---|---|---|
| Missing ownership check (IDOR) | M | GET /orders/:id loads by ID without checking the order belongs to the caller |
| Checks only in the UI | J/M | The button is hidden for non-admins, but the API doesn't check |
| Role checked, tenant not | M | An admin of company A can act on company B's data |
| Mass assignment | M | db.users.update(id, req.body) lets users set role, isAdmin, balance |
Injection
| Bug | Who | Signal |
|---|---|---|
| SQL injection | J/M | `SELECT … WHERE id = ${id}` or string concatenation into queries |
ORDER BY / column names from input | M | Sort fields can't be parameterised. They need an allow-list |
| NoSQL injection | M | Passing req.body filters to Mongo ({ $ne: null }) |
| Command injection | M | exec(`convert ${file}`). Use execFile with an argument array |
| Path traversal | M | path.join(uploads, req.params.name) with ../../etc/passwd |
eval / new Function | J/M | Executing anything built from input |
| Log injection | M/S | Newlines in user input forging log lines |
| ReDoS | S | Nested quantifiers like (a+)+ on user input |
| Prototype pollution | M/S | Deep-merging user JSON into objects (__proto__) |
Browser
| Bug | Who | Signal |
|---|---|---|
| XSS | M | innerHTML, dangerouslySetInnerHTML, unescaped templates, javascript: URLs |
| CSRF | M | Cookie-authenticated state changes with no SameSite or token |
| Open redirect | M | res.redirect(req.query.next) |
| CORS reflecting any origin with credentials | M/S | Access-Control-Allow-Origin: <request origin> plus Allow-Credentials: true |
| Weak cookie flags | M | Session cookies without HttpOnly, Secure, SameSite |
Tokens in localStorage | M | Any XSS can read and exfiltrate them |
Tokens and secrets
| Bug | Who | Signal |
|---|---|---|
| JWT not verified | M | jwt.decode used where jwt.verify was needed |
| JWT without expiry or with algorithm confusion | M/S | No exp; accepting whatever alg the header says |
| Predictable tokens | M | Math.random() for reset tokens, invite codes, session IDs. Use crypto.randomUUID / randomBytes |
| Non-constant-time comparison | S | === on secrets and signatures. Use timingSafeEqual |
| Reset tokens reusable or never expire | M | No expiry and no single-use marking |
| Secrets in logs or the client bundle | M | Logging request headers; a server key in a NEXT_PUBLIC_ variable |
| Secrets committed | J/M | Keys in the repo, .env committed, keys in test fixtures |
Abuse
| Bug | Who | Signal |
|---|---|---|
| No rate limit on login, OTP, reset | M | Unlimited guesses |
Rate limit by X-Forwarded-For | M/S | Trusting a header any client can set |
| No input size limits | M | Unbounded bodies, uploads, array lengths: memory exhaustion |
| Unchecked uploads | M | Type from the client's Content-Type; no size cap; user-chosen filenames |
| SSRF | M/S | The server fetches a URL the user supplies (webhooks, image proxies, "import from URL") |
| User enumeration | M | "No account with that email" vs "wrong password" |
| Leaky errors | J/M | Stack 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
| Bug | Who | Signal |
|---|---|---|
| No timeouts | M | Any fetch, DB or SDK call without one |
| Unbounded growth | M | Caches, arrays, maps, queues or listener lists that only ever grow |
Queries without LIMIT | M | List endpoints that return every row |
| Assumed input size or shape | M | "There will never be more than 100 items"; a fixed number of address lines |
| Hardcoded URLs, paths, magic numbers | J/M | localhost, /Users/…, 3000, 86400 sprinkled through the code |
| Server time zone dependence | M | new Date().getHours() on a server whose zone isn't fixed |
| Brittle parsing of third-party payloads | M | Assuming every field of a webhook is present and typed |
| Relying on undocumented behaviour | M/S | Object key order, a library's internal format, an API's default sort |
Lifecycle
| Bug | Who | Signal |
|---|---|---|
| No graceful shutdown | M | No SIGTERM handling: in-flight requests and jobs killed on every deploy |
| Startup-order dependence | M | Crashes if the DB is slower to start than the app |
| Retry everything / retry nothing | M | No distinction between transient and permanent failures |
| Unsafe flag defaults | M | A feature flag that defaults to on when the flag service is unreachable |
Deploy ordering
| Bug | Who | Signal |
|---|---|---|
| Code needs schema that isn't there yet | M/S | New code reads a column the migration hasn't added yet, or old code breaks on a dropped column |
| Locking migrations | S | Adding a column with a default or an index without CONCURRENTLY on a big table |
| Breaking old clients or in-flight jobs | M/S | Renamed fields, changed message formats, removed endpoints |
| Dev settings in production | J/M | .env loading, debug flags or permissive CORS reaching prod |
Performance as an outage
| Bug | Who | Signal |
|---|---|---|
| Accidental O(n²) | M | includes/find/indexOf inside a loop over the same data size |
| Blocking the event loop | M | readFileSync, pbkdf2Sync, giant JSON.parse or sync compression in a request handler |
| Whole tables in memory | M | Loading every row to filter or count in JavaScript |
| Work repeated in loops | M | Regexes compiled, config parsed, or connections opened inside a hot loop |
| Huge logs | M | JSON.stringify of large objects on every request |
Operability
| Bug | Who | Signal |
|---|---|---|
| PII or secrets in logs | M | Logging full request bodies, emails, tokens |
| Logs with no context | M | console.log('failed'): no ID, no input, no cause |
| No correlation IDs | M/S | Can't follow one request across services |
| Errors that never alert | M | Swallowed errors mean nothing reaches monitoring |
| Global state that makes tests flaky | M | Module-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.
| Bug | Who | Signal |
|---|---|---|
| Assertion on an un-awaited promise | M | expect(fn()).rejects… without await; the test passes before it checks |
| No assertion at all | J | A test that only calls the function |
| Mocking the thing under test | M | The mock returns the expected value, so the test tests the mock |
| Time- or order-dependent tests | M | Real Date.now(), real timers, tests that pass only in a certain order |
| Shared state between tests | M | Module-level state or DB rows leaking from one test to the next |
| Blindly updated snapshots | M | Huge snapshots nobody reads |
| Happy path only on risky code | M | Money, auth and retries tested only with valid input |
Library and API traps
| Bug | Who | Signal |
|---|---|---|
fetch vs axios error semantics | M | fetch resolves on 500; axios throws on 4xx. Code written for one, used with the other |
| Mutable date libraries | M | Moment-style date.add() mutating a shared date |
lodash merge on user input | M/S | Prototype pollution through a deep merge |
| Sync vs async variants | M | bcrypt.hashSync in a request handler; too low or too high a cost factor |
| Misread defaults | M | No 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.
| Smell | Why it matters |
|---|---|
| Logic duplicated in two places | The copies drift: two different price calculations |
| Names that lie | getUser() that creates a user; isValid that also saves |
| Comments that disagree with the code | One of them is wrong. Find out which |
| Boolean parameters | send(user, true, false): easy to swap |
| God functions | 300-line handlers where every change risks everything |
| Dead code | Paths nobody tests, still reachable by some input |
Frontend (catalogue only, drills later)
| Bug | Who | Signal |
|---|---|---|
| Stale closures | M | An effect or callback reading an old value of state |
| Wrong effect dependencies | M | Missing deps (stale data), or an object dep (infinite loop) |
| Fetch races | M | An older response overwrites a newer one; no AbortController |
key={index} | M | Reordered or deleted list items keep the wrong state |
| Controlled ↔ uncontrolled switch | J/M | value={x} where x starts undefined |
| Leaked listeners and controllers | M | Subscriptions without cleanup in the effect |
| Hydration mismatch | M | Date.now(), Math.random(), window used during render |
| Infinite re-renders | J/M | Setting state during render; new object deps |
| Double-submitted forms | J/M | No disabled state or idempotency key |
| Optimistic updates not rolled back | M | UI shows success after the server rejected |
JSON.parse(localStorage…) crashing | M | Corrupt 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:
| Factor | Question |
|---|---|
| Severity | What's the worst outcome: lost money, leaked data, an outage, a wrong number, a typo? |
| Likelihood | Does it happen on every request, under load, or only with a hostile user? |
| Blast radius | One 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
- Next: Reading in Passes: the method that turns this catalogue into a review.
- JS Code Review: L4 in depth.
- Debugging: when the bug has already happened.