TLDR: A Zod
.max(500)added for security silently became a hard limit on real batch operations. The fix is a sequential chunking loop — and every caller that sends the batch needs it, including Undo.
The feature
We were building a bulk due-date tool for an ecommerce business's internal webinar project management app.
The ask: click the ⋯ menu on any milestone card, pick "Update all due dates…", and either shift every task by ±N calendar days or set them all to one date — live before/after preview, toast with Undo.
It shipped clean on June 4. 74 Vitest tests, zero TypeScript errors, zero ESLint warnings, auto-deployed to Vercel.
Clean build. All green. Feeling good.
The audit found it anyway
A day later we ran a full WIN+CSO security audit across the app. The audit surfaced finding W8: a milestone with more than 500 tasks would get the entire shift/set operation hard-rejected — nothing applied, no partial success, just an error.
The culprit? A Zod schema in src/lib/validation.ts:
export const DUE_DATE_MAP_CAP = 500;
// ...
.max(DUE_DATE_MAP_CAP, "Too many tasks in one update.")
That .max(500) was intentional. We put it there to prevent oversized payloads from hitting the server action — a legitimate security move. Bounded schemas on mutations are good practice. The tests even covered the exact-at-cap and over-cap edge cases.
The problem was the caller didn't know the cap existed.
writeDueDates() in TaskList.tsx just serialized the whole updates array into a single FormData blob and fired it. A 200-task milestone? Fine. A 600-task milestone? Zod rejects the entire body. "Too many tasks in one update." The user gets an error. Nothing moves.
So what did I fix on the server? Nothing. The server was right.
The fix: chunk the caller
The schema cap belongs where it is. What was missing was a chunking loop on the write path.
for (let i = 0; i < updates.length; i += DUE_DATE_MAP_CAP) {
const chunk = updates.slice(i, i + DUE_DATE_MAP_CAP);
const fd = new FormData();
fd.set("event_id", String(data.eventId));
fd.set("updates", JSON.stringify(chunk));
if (!(await runAction(setTaskDueDates(fd)))) {
if (i > 0) {
toast.error(
`${i} of ${updates.length} updates were applied before the failure; the rest were left unchanged.`
);
}
setOptimisticTasks(navRef.current.serverTasks);
return;
}
}
onSuccess?.();
Sequential chunks of ≤500. Abort on the first failure. If it fails mid-run, the toast names exactly how many were applied before it stopped — so the user isn't staring at a mystery.
One more thing: Undo goes through writeDueDates() too. Same path, same chunking, automatically. If I'd wired Undo through a separate code path, I would've missed it. Worth making sure your rollback logic doesn't skip the same guardrails as the forward write.
Why this one stuck with me
Here's what's sharp about it: the tests PASSED on June 4. The exact-at-cap and over-cap cases were covered. What wasn't covered was the caller sending a real-world payload that exceeds the cap — because no test milestone had 501+ tasks.
The schema was doing its job. The caller was naive about the schema's limits. Those are two different failure modes, and only one of them was tested.
The pattern I'm carrying forward: whenever I put a .max() on a batch endpoint for safety, I also ask — does every caller that feeds this endpoint know about the cap and handle it? If the answer is "it just passes the whole array," the chunking layer is missing.
Security bounds on the server. Chunking logic on the caller. Both, every time.
P.S.
DUE_DATE_MAP_CAPis exported fromvalidation.tsand imported directly inTaskList.tsx— so if the limit ever changes, it moves in one place. That part I got right from the start.