M
MeshWorld.
Claude AI Code Review Developer Tools Node.js TypeScript Security Workflow 8 min read

I Used Claude to Review My Code for a Week. Here Is What It Caught.

By Vishnu Damwala

I write code every day and I still get code reviews from teammates. Not because I’m bad at my job, but because a second pair of eyes catches things a first pair misses — it’s just how it works.

The question I wanted to answer: can Claude act as that second pair of eyes? Not for architecture decisions or product direction. Just the thing a good reviewer does — reads your diff, notices what you didn’t, and says “hey, did you think about this?”

So I ran an experiment. For one full week, before submitting any PR, I pasted my changes into Claude and asked it to review them. I kept notes. Here’s what actually happened.


How I set it up

The project: a Node.js API with TypeScript, Fastify, and PostgreSQL. Standard CRUD plus some async background jobs. Around 40,000 lines of existing code.

My prompt was deliberately simple:

“Review this code change. Look for bugs, edge cases, security issues, and anything that might break in production. Be specific.”

I’d paste the diff or the relevant file. Sometimes I’d add context: “This is a new endpoint. It’s called from the mobile app when a user completes onboarding.”

Then I’d read what came back and decide what to act on.


Day 1: The null check I didn’t write

First PR was a new API endpoint that looked up a user and returned their subscription status. I thought it was clean.

Claude flagged this:

const user = await db.users.findOne({ id: userId });
return { status: user.subscription.status };

“If findOne returns null when no user is found — which is typical — accessing user.subscription.status will throw a TypeError at runtime. You’ll want to handle the case where user is null before accessing its properties.”

I’d written a hundred endpoints like this. I’d checked for this in most of them. This one I hadn’t. In production, a request with an invalid user ID would have thrown a 500 instead of returning a clean 404.

I added the null check. Fixed.


Day 2: An async race condition I’d never have caught in review

This one was subtle. I had a job that updated a user’s record and then sent a notification:

async function completeOnboarding(userId: string) {
  await db.users.update(userId, { onboardingComplete: true });
  await notifications.send(userId, 'welcome');
}

Claude’s note:

“If the database update succeeds but the notification call fails, the user’s record shows onboardingComplete: true but they never receive the welcome message. Depending on whether notifications.send is idempotent, retrying the job could send duplicate notifications or fail silently. Consider wrapping this in a transaction or handling partial failures explicitly.”

This is exactly the kind of thing that goes wrong in production and takes 45 minutes to debug because the symptoms don’t point to the cause. My teammates would probably have missed this too — it looks right at a glance.

I added a try/catch around the notification call with a separate retry queue. Not perfect, but better.


Day 3: The security issue I definitely didn’t intend

A filter endpoint that let users search their own documents by metadata:

const results = await db.documents.findAll({
  where: {
    userId: currentUser.id,
    ...req.body.filters  // user-provided
  }
});

Claude:

“Spreading user-provided input directly into a database query filter can allow users to override the userId constraint — for example, by sending { userId: 'other-user-id' } in the request body. This could be an authorization bypass. You should either whitelist allowed filter keys or strip any keys that override security constraints.”

I sat with this for a moment. Claude was right. A malicious user who knew the field name could have fetched another user’s documents. I hadn’t intended that. The fix was easy:

const allowedFilters = ['status', 'createdAfter', 'tag'];
const safeFilters = pick(req.body.filters, allowedFilters);

But I wouldn’t have caught it. My mental model was “I set userId so it’s scoped to the current user” — I just didn’t think about what the spread operator was doing.


Day 4: Where Claude was wrong

In fairness, Claude isn’t always right.

I had a function that returned early if a condition was met:

function processPayment(order: Order) {
  if (order.status === 'paid') return;  // already processed
  // ... rest of payment logic
}

Claude flagged this as a potential issue:

“Returning undefined implicitly might cause problems for callers expecting a result. Consider making the return explicit or throwing an error if calling this function on an already-paid order is a logic error.”

In this context, it’s not a problem. The function is fire-and-forget, the caller doesn’t use the return value, and the early return is intentional — it’s idempotency protection. This was Claude pattern-matching on what early returns sometimes mean without enough context about what this specific function was doing.

I ignored it. Good review process means knowing when to push back.


Day 5: The obvious thing that was right in front of me

Sometimes the review isn’t about bugs. It’s about clarity.

I had a function called handleUserAction. It did four different things depending on the action type. Claude’s feedback:

“This function is doing quite a bit: validating input, calling different services based on action type, updating the database, and sending notifications. Consider extracting each case into its own function — it would make the logic easier to test and the error handling clearer.”

I knew this was messy. I’d written it messy because I was in a hurry. Having it stated plainly pushed me to refactor it before it got into main and became someone else’s problem.


Day 6: Performance I hadn’t thought about

A query that loaded related data:

const orders = await db.orders.findAll({ userId });
const enriched = await Promise.all(
  orders.map(order => loadProductDetails(order.productId))
);

Claude:

“If orders returns 100+ rows, this fires 100+ individual database queries for product details. Consider using a SELECT ... WHERE id IN (...) query instead to load all product details in a single round trip, or use a join at the query level.”

Not a bug. Not a security issue. But a future-you problem. I’d written it this way because it was clear and easy, but it would have been a slow disaster at scale. I refactored to a single query with a join.


Day 7: The one I’m most glad it caught

The last day. A background job that ran on a schedule and deleted old temporary files:

async function cleanupTempFiles() {
  const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
  await db.tempFiles.deleteMany({
    createdAt: { lt: cutoff }
  });
}

Claude:

“This deletes records where createdAt is less than the cutoff, but there’s no index on createdAt. Depending on the table size, this could do a full table scan and lock the table for an extended period — potentially blocking other queries. Make sure there’s an index on createdAt for this table, or use a LIMIT and batch the deletes.”

We were about to migrate this job to run hourly. The temp files table had about 800,000 rows. Without an index, the delete would have blocked the table every hour. Claude caught it before we found out the hard way.


What I actually learned

It’s best at specific, bounded things. Null checks, missing error handling, obvious security patterns, N+1 queries — Claude is consistently good at these. It has seen a lot of code and knows the common failure modes.

It struggles with intent. It doesn’t know why you wrote something, only what it does. That leads to false positives when what you wrote is intentional and correct but looks like a mistake.

It’s not a replacement for human review. A teammate who knows the system will catch things Claude never will — “this conflicts with how we handle retries in the jobs queue” or “the mobile team has a bug that means this field might be empty even though the type says it won’t be.” Context that lives in people’s heads, not in code.

The real value is the second set of eyes, instantly. My teammates are busy. Getting a review sometimes takes a day. Claude gives me feedback in 30 seconds, before I’ve context-switched away from the code. That immediacy changes how much I act on it.

I still get human reviews. I still get things Claude missed reviewed by people who know the system. But now there’s a first pass that catches the obvious stuff before it wastes anyone’s time.

That, it turns out, is worth a lot.

Reference: Claude API & Code Cheat Sheet