RizTech Academy logo
RizTech Academy
Engineering Practices Across a StackLesson 4 of 540 min

The bugs that only exist between layers

Every bug in this lesson is one you cannot find by reading a single file. Each lives in the gap between two layers, where both sides are individually correct and the pair is wrong. They are also, every one of them, a bug this course hit while building the Kirana Store.

The date that was stored a day early

The worst bug of the whole build, and worth the space.

const deliveryDate = new Date(body.date);
deliveryDate.setHours(0, 0, 0, 0);

That looks like "midnight on the delivery date", and on a machine set to IST it is midnight IST — which is 18:30 the previous day in UTC. Postgres stored the previous day. Every delivery for an order placed after midnight IST was on the wrong day.

What made it survive testing is the second half: the API echoed back the date the client had sent, not the date it stored. The response was right. The database was wrong. Nobody looked at the database, because the response was right.

/** Parse "2026-09-27" as UTC midnight, so the same day is the same instant. */
export function parseIsoDate(text: string): Date {
  const [y, m, d] = text.split("-").map(Number);
  return new Date(Date.UTC(y, m - 1, d));
}

Plus @db.Date on the column, so Postgres stores a calendar date and cannot drift at all.

A calendar date and an instant are different types. "27 September" is not a moment — it is a different moment for everyone. createdAt is an instant and belongs in timestamptz. deliveryDate is a date and belongs in date. Mixing them is a bug that only appears for users in some timezones, at some hours, which is the hardest kind to reproduce.

And echo back what you stored, not what you were sent. Read it from the row after writing. If the response had come from the database, this bug would have lasted four minutes.

Money that is a float somewhere

0.1 + 0.2 is 0.30000000000000004 in JavaScript, in Python, and in Postgres float8. Integers of paise all the way down: Int in the schema, Paise in the shared types, amountPaise in every name, and conversion to rupees only at the moment of display.

The cross-layer trap is a single NUMERIC or float column, or a JSON round trip through a language that does not have integers. One layer being careful is not enough — a bill that is one paisa wrong is a bill a customer does not trust.

Two requests, one last bag of atta

Two customers check out at the same instant. Both read stock: 1. Both pass the check. Both write stock: 0. You have sold two and have one.

Reading and then writing is never safe when something else can write in between.

// wrong
const product = await prisma.product.findUnique({ where: { id } });
if (product.stock < quantity) throw …
await prisma.product.update({ where: { id }, data: { stock: product.stock - quantity } });
// the condition is in the write
const updated = await prisma.product.updateMany({
  where: { id, stock: { gte: quantity } },
  data: { stock: { decrement: quantity } },
});

if (updated.count === 0) {
  throw new AppException(ErrorCode.INSUFFICIENT_STOCK, HttpStatus.CONFLICT, "Not enough stock.");
}

One statement. The database checks and decrements atomically, and count === 0 tells you somebody else got there first. This is compare-and-swap, and it is the pattern for any "only if it is still true" update — stock, a slot with a capacity, a status that may only move forwards.

Note decrement, not stock: product.stock - quantity. The second sends a value you computed from a stale read; the first tells the database to do the arithmetic on whatever is there now.

For anything spanning two tables, a transaction:

await this.prisma.$transaction(async (tx) => { … });

And know what your transaction actually guarantees. Postgres's default is Read Committed, which stops dirty reads and does not stop the two-customers race above. The where clause is what makes it safe, not the transaction around it.

The state that is stale the moment it is rendered

A price rendered into a page is a price as it was when the page was built. The customer leaves the tab open for an hour.

// Never trust the amount in the event either. If it does not match what we
// asked for, something is wrong and a human should look.
if (event.amountPaise !== payment.amountPaise) { … }

The general rule: re-read and re-check at the moment of commitment. Prices, stock, slot capacity and permissions are all verified again inside the checkout, against the database, not against what the page was holding. If they have moved, PRICE_CHANGED and show the customer what happened — much better than charging yesterday's price or silently charging today's.

And after a write that changes what a page shows:

revalidatePath("/orders", "layout");

Forget it and the customer places an order, lands back on a cached list, and does not see it. They order again.

Two tabs are two clients

The same customer, two tabs, one cart. Tab A adds atta; tab B still shows the old cart and submits. Everything a multi-user race can do, one determined user with two tabs can do too — and they do it by accident, constantly, because the phone put the first tab to sleep.

Test it: open two tabs, act in both, and see what the server does. It takes two minutes and it finds the check you put in the component instead of the service.

Trusting the shape of a response

const orders: OrderSummary[] = await res.json();

That annotation is a claim, not a check. res.json() returns any, and TypeScript believes whatever you tell it. If the API changed, orders[0].totalPaise is undefined, formatPaise(undefined) gives you ₹NaN on the page, and the type system said nothing.

Two things to add. Check res.ok, because a 404 or a 500 does not make fetch throw — it resolves happily and you parse an error body as if it were data:

if (!res.ok) {
  const body = (await res.json()) as ApiErrorBody;
  throw new ApiError(body.code, body.message, body.details);
}

And for anything crossing the boundary, validate the shape at runtime rather than asserting it. A schema library (Zod and its like) turns a lie into a caught error at the edge, where you can do something about it, instead of NaN in the middle of a page.

Secrets, and the boundary that is easy to forget

NEXT_PUBLIC_ variables are baked into the JavaScript bundle at build time. Not read at runtime, not hidden, not scoped to the server. Put an API secret in one and you have published it to every visitor, and rebuilding is not enough — the old bundle is in caches and CDNs.

Two follow-ons that catch people. Changing a NEXT_PUBLIC_ value requires a rebuild, not a restart, which is why a "config change" on the deploy platform appears to do nothing. And a server component may read a real secret, while a client component may not — so a component that reads process.env.API_SECRET and later grows a "use client" at the top has just become a leak, with no error at all.

Failure in one layer must not look like success in another

try {
  await sendConfirmationEmail(order);
} catch {
  // swallowed — the order is placed and nobody is told
}

Decide, explicitly, which failures are fatal to the request. The email failing should not roll back a paid order — but it must be logged loudly and retried, not swallowed. The stock decrement failing must fail the order.

Write it down for each step: fatal, or logged and continued? A catch with nothing in it is that decision made by accident, in favour of losing the information.

And the one that surprises everyone once: a server action must be async.

export function placeOrder() { … }        // typechecks, breaks the build
export async function placeOrder() { … }  // correct

tsc is happy; next build is not. It is a good reminder that the typecheck is not the whole gate — the build is a different check, and both run before you merge.

Check your work

Why setHours(0,0,0,0) stored the wrong day: it means local midnight, which is the previous day in UTC.

Why it survived testing: the API echoed the request, not the row.

Calendar date versus instant: different types, date versus timestamptz.

Why money is integer paise everywhere: one float anywhere reintroduces the error.

Why read-then-write loses a race: something else writes in between; put the condition in the where and check count.

Why decrement not subtraction: subtraction sends a value from a stale read.

What Read Committed does not prevent: exactly that race.

What to re-check at commitment: price, stock, capacity, permission.

What revalidatePath prevents: a customer not seeing their own order and ordering twice.

Why two tabs matter: one user reproduces multi-user races by accident.

Why a type annotation on res.json() is a lie: it is a claim, not a check — and fetch does not throw on 404 or 500.

Why NEXT_PUBLIC_ is not a secret: baked into the bundle at build time, and cached after.

What an empty catch decides: that losing the information is acceptable.

Practice

  1. Store a date with setHours(0,0,0,0) from a machine set to IST and read the row back in UTC. Then fix it with a UTC helper.
  2. Make an endpoint echo the request instead of the stored row, and write the test that would have caught the date bug anyway.
  3. Put a price in a float column, add three of them, and compare with paise.
  4. Run two checkouts against one unit of stock at the same moment. Confirm you oversold, then fix it with updateMany and a where.
  5. Change decrement to a subtraction from a prior read and reproduce the race.
  6. Delete a revalidatePath call and place an order. Watch the list not update.
  7. Open two tabs, add to the cart in both, and submit the stale one.
  8. Change a field name in the API and see what the front end renders. Then add a runtime schema check at the boundary.
  9. Return a 500 from an endpoint and confirm your fetch does not throw.
  10. Put a fake secret in a NEXT_PUBLIC_ variable, build, and grep the bundle.
  11. Change a NEXT_PUBLIC_ value and restart without rebuilding. Explain what you see.
  12. For every catch in your API, write "fatal" or "log and continue" beside it.
  13. Remove async from a server action and run tsc, then next build.

Next: reviewing a change that touches all of this at once.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship