Skip to content

Why a Form Date Slips Back One Day: the Payload Follows the Data

Adityo Guni Waluyo

A date input ships a plain string, the old payload carried a UTC instant. How one day vanished and the contract that stopped it.

TL;DR

A date input saved as December 20 came back as December 19 because wrapping the plain yyyy-mm-dd string in a Date, then calling toISOString, shifted local midnight to the previous day in UTC-negative timezones. The fix anchors the input string at UTC midnight explicitly. Keeping the Go backend strict on RFC 3339 prevents this class of bug entirely.

The list of events in the KotaPortal admin panel showed something off: an admin had just saved an event dated December 20, 2026, and the row in the table read December 19, 2026. No error, no warning, the form reported success. The date quietly moved back a day.

My first suspect was the backend: the Go date parser surely mishandled the user's timezone. Tracing the data flow from browser to database proved that guess wrong. The payload was already broken on the client side, before the request ever left.

An Input Value Is a String, Not a Time

Here is the fact that matters: the value of a date input is a plain yyyy-mm-dd string with no time component at all [1]. The old code wrapped that string in a Date object at local midnight, then called toISOString to ship it. That is the gap: toISOString always converts to UTC and appends the Z suffix [2].

In a WIB browser, local midnight of December 20 is 17:00 UTC on December 19. The serialization came out as 2026-12-19T17:00:00Z. The calendar in the payload lost a day before the backend had a say.

// before: wrapped in a Date, then serialized
new Date(value).toISOString()
// "2026-12-20" in a WIB browser becomes "2026-12-19T17:00:00Z"

// now: the input string is anchored at UTC midnight explicitly
`${value}T00:00:00Z`
// "2026-12-20" stays "2026-12-20T00:00:00Z"

The Backend Refuses the Guess

On the other side, the Go DTO uses a pointer-to-time-time. Its JSON contract is strict: a quoted RFC 3339 string with sub-second precision, validated by a strict RFC 3339 parser during unmarshal [3]. A bare YYYY-MM-DD payload is always rejected. I consider that rigidity a feature, not an obstacle: a field that stores a calendar date should not be fed an instant that merely happens to be nearby.

The amusing part is that JavaScript already agrees. Parsing a plain yyyy-mm-dd string treats it as UTC, not local time [4]. What corrupts the intent of the data is not the format, it is the step that wraps the value into a local-time Date object first and only then surrenders to UTC.

That leaves a design temptation worth naming: let the backend loosen its parser and accept plain yyyy-mm-dd. I considered it briefly and dropped it. Accepting two formats for one field means every future client has to guess which shape is expected, and that guess is what will shift days again in some other timezone. One format per meaning is cheaper to maintain than a friendly but slippery parser.

The Payload Follows the Kind of Data

The fix is as plain as it gets: an explicit string template that appends T00:00:00Z to the input value as-is. December 20 goes out exactly as 2026-12-20T00:00:00Z, and the calendar survives the round trip. A live test in the project proves input 2026-12-20 now stores as 2026-12-20, no shifting.

Part of what makes this bug so slippery: two clocks with different definitions meet in one string. The browser clock follows the user's machine timezone, the payload clock follows format rules. While they negotiate without an agreement, the offset just waits. Save the event during WIB working hours and its UTC instant already touches December 19. Fill the same form from a server set to UTC and the bug never appears. The victim is always a user west of UTC, and testing from an eastern timezone is the easiest way to catch it.

I left the backend strict, with no custom unmarshaler accepting bare dates. The contract I chose: a field whose semantics are calendar gets a deliberately pinned time-of-day, not a parser that loosens validation. If a field someday truly needs an instant, it will still travel the full RFC 3339 path, and the two kinds of data will not be confused again.

The web platform itself is slowly admitting that a date without a time is its own type: Temporal.PlainDate is designed exactly for calendar dates without a timezone, though browser support has not reached Baseline yet [5]. Until that support is everywhere, hand-building the UTC string remains the most honest pattern I can rely on: a date field ships as a date, and the Z at the end is the witness that the instant was pinned to UTC midnight on purpose.

## Sources [1] https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date [2] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString [3] https://github.com/golang/go/blob/master/src/time/time.go [4] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse [5] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainDate

Related articles