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
- 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. - Make an endpoint echo the request instead of the stored row, and write the test that would have caught the date bug anyway.
- Put a price in a float column, add three of them, and compare with paise.
- Run two checkouts against one unit of stock at the same moment. Confirm you
oversold, then fix it with
updateManyand awhere. - Change
decrementto a subtraction from a prior read and reproduce the race. - Delete a
revalidatePathcall and place an order. Watch the list not update. - Open two tabs, add to the cart in both, and submit the stale one.
- Change a field name in the API and see what the front end renders. Then add a runtime schema check at the boundary.
- Return a 500 from an endpoint and confirm your
fetchdoes not throw. - Put a fake secret in a
NEXT_PUBLIC_variable, build, and grep the bundle. - Change a
NEXT_PUBLIC_value and restart without rebuilding. Explain what you see. - For every
catchin your API, write "fatal" or "log and continue" beside it. - Remove
asyncfrom a server action and runtsc, thennext 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