Our free plan allows 31 private diaries per calendar month and 100 notes total. Enforcing that sounds trivial: count what the user created this month, compare against the limit, and send a "you’re almost at your limit" push at 90% and 100%. The catch is the phrase "this month." It shows up in at least three places — the count query, the usage display, and the notification dedup key — and if any two of them disagree about when a month starts and ends, the quota silently misbehaves at exactly one moment: midnight on the 1st.
That is the worst kind of bug. It is invisible in every test that runs at 2pm, it never reproduces on the developer’s machine if they happen to be in UTC, and it only surfaces for real users in non-UTC timezones during the few hours around a month rollover. This post is about making every "this month" in the quota path derive from the same half-open UTC interval, and — just as importantly — about the one place we deliberately did not touch.
TL;DR
- A monthly quota needs one definition of "month," used identically by the count, the display, and the notification dedup key.
- We centralized it in
@storyie/subscriptionas a half-open UTC interval:[first of this month 00:00:00Z, first of next month 00:00:00Z). - The end is exclusive, built with
Date.UTC(year, month + 1, 1)— no "last millisecond of the month" guessing, and December rolls into January for free. - The notification dedup key (
YYYY-MM) is generated from the same UTC basis as the counted window, so "already notified this period?" can’t drift from "how much is used this period?" - We kept Expo’s diary-list cache key in local time on purpose — it’s a display grouping, not an aggregation boundary, and UTC-ifying only it would split the cache.
- One JST midnight-crossing test pins the semantics:
2026-01-01T00:30:00+09:00→2025-12, not2026-01.
The midnight bug: two definitions of "month"
The content-limit path had grown two independent notions of the current month. The diary count used UTC month boundaries. The notification service that decides whether to send the "90% used" push generated its dedup key — the periodKey it writes to notificationLogs — in local time, with its own private helper. The same YYYY-MM logic was also duplicated in the shared subscription package, giving three implementations with two different timezone bases.
For most of the month, nobody notices: it’s the same month in every timezone. The divergence is a knife-edge. Consider a user in Tokyo (UTC+9) at 2026-01-01T00:30:00+09:00. In UTC that instant is still 2025-12-31T15:30:00Z — December. A UTC count says "this is still December’s usage." A local-time key says "January." The dedup key now names a month the count doesn’t agree is current, so the "have we already sent the 100% notification this period?" lookup can miss and re-fire, or skip a genuinely new period.
Because our production notifier runs server-side on Lambda in UTC, the local-time key happened to resolve to UTC values at runtime — the bug was latent, not active. But "correct only because the runtime’s clock is UTC" is a property no one wrote down and nothing enforced. The moment that code runs anywhere non-UTC — a local reproduction, a future edge runtime — the two definitions split. We wanted the alignment to be structural, not accidental.
One half-open UTC interval
The fix is a single source of truth in the platform-agnostic @storyie/subscription package. Everything that needs "the current month" imports it.
// packages/subscription/src/utils/period.ts (excerpt)
export function getCurrentMonthPeriod(): DatePeriod {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
// First day of current month at UTC midnight
const start = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0));
// First day of next month at UTC midnight (exclusive end)
const end = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0));
return { start, end };
}Two decisions carry their weight here. First, everything reads from getUTC* accessors and constructs with Date.UTC, so the boundaries never depend on where the code runs. Second, the interval is half-open — start inclusive, end exclusive — which the type makes explicit:
// packages/subscription/src/types/period.ts
export interface DatePeriod {
/** Period start (inclusive, UTC) */
start: Date;
/** Period end (exclusive, UTC) */
end: Date;
}An inclusive end would force us to answer an awkward question: what is the last instant of January? 23:59:59? 23:59:59.999? Whatever precision you pick, there’s a smaller gap just beyond it, and a diary created in that gap belongs to no month. The exclusive end sidesteps the question entirely — [Jan 1 00:00:00Z, Feb 1 00:00:00Z) covers every instant of January with no gap and no overlap, and the boundary instant belongs unambiguously to February. Passing month + 1 to Date.UTC also means December (month = 11) produces Date.UTC(year, 12, 1), which JavaScript normalizes to January 1st of the next year — the rollover needs no special case.
Two period keys for two kinds of quota
Not every limit resets monthly. Diaries do (31/month); notes accumulate toward a lifetime cap (100 total). So there are two key shapes, both derived from UTC so they can’t drift from the boundaries above.
// packages/subscription/src/utils/period.ts (excerpt)
export function getMonthlyPeriodKey(date: Date = new Date()): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${year}-${month}`; // e.g. "2026-01"
}
export function getCumulativePeriodKey(date: Date = new Date()): string {
const year = date.getUTCFullYear();
return `${year}-all`; // e.g. "2026-all"
}These keys are what make the notification "at most once per period" — the notifier writes a row to notificationLogs keyed by (userId, notificationType, contentType, periodKey) and checks for it before sending again. The key is chosen by content type:
// apps/web/services/contentLimitNotificationService.ts (excerpt)
const periodKey =
contentType === "diary" ? getMonthlyPeriodKey() : getCumulativePeriodKey();Because periodKey and the usage count both come from UTC now, the dedup question ("already notified this period?") is asked about the same window the percentage was computed against. Before, one was UTC and the other local — the alignment we depended on was a coincidence of the deployment environment.
The count and the display read the same boundary
Centralizing the interval only pays off if the readers actually use it. The write-path gate that decides whether a user is over quota counts diaries in the half-open window [start, now):
// apps/web/services/content/contentUsageService.ts (excerpt)
export async function countContentUsage(
userId: string,
contentType: ContentUsageType
): Promise<number> {
if (contentType === "diary") {
const { start } = getCurrentMonthPeriod();
return diaryQueries.countUserPrivateDiariesInPeriod(userId, start, new Date());
}
return noteQueries.countUserPrivateNotes(userId);
}The read-only usage endpoints — GET /api/v1/content-limit and the usage route that backs the "X of 31 used" UI — import the very same getCurrentMonthPeriod(). Across the whole web app there is now exactly one definition of it, in @storyie/subscription; no caller reimplements the boundary in local time. When "this month" is a single imported function, a reader can’t accidentally disagree with the enforcer.
The trade-off: not every "current month" should be UTC
The tempting next step is to grep for every getMonth() and UTC-ify it. We didn’t, and the reason is the more interesting part of the story.
The Expo app has a syncService.getCurrentMonthKey() that also produces a YYYY-MM string — but it feeds queryKeys.diaries.list(monthKey), the cache namespace for the home pager’s month view. Five sibling formatters (the diary list hook, the day view, the streak counter, the mutation cache, the widget) all derive that same key from a Date in local time, because they’re grouping a user’s diaries by the day the user experienced them. That’s a display boundary, not an aggregation boundary.
UTC-ifying only syncService would have been a regression: the prefetch would write diaries into one cache bucket while the reader hooks read from another around midnight on the 1st in any non-UTC timezone. Migrating all six to UTC would change what "this month’s diaries" means on the home screen — the user in Tokyo would see their late-evening entry filed under the previous month. So we drew the line by purpose: UTC where we aggregate or enforce a limit, local calendar where we present a user’s day. The subscription package moved to UTC; the display cache keys stayed local, internally consistent with their five siblings.
Pinning the semantics with a timezone-crossing test
The whole class of bug is invisible unless a test deliberately crosses a boundary in a non-UTC offset. So the key tests don’t assert on "now" — they feed a fixed instant that is one month in local time and another in UTC:
// packages/subscription/__tests__/period.test.ts (excerpt)
test("uses UTC semantics — a date that is Dec 2025 in UTC but Jan 2026 locally", () => {
// 2026-01-01T00:30:00+09:00 = 2025-12-31T15:30:00Z → UTC month is December 2025
const date = new Date("2026-01-01T00:30:00+09:00");
expect(getMonthlyPeriodKey(date)).toBe("2025-12");
});2026-01-01T00:30:00+09:00 is 30 minutes past midnight in Tokyo but still 2025-12-31T15:30:00Z. The assertion — "2025-12", not "2026-01" — is the entire point: the key follows UTC, so it agrees with the counted window even at the one instant where local and UTC calendars disagree. The cumulative key has the mirror test ("2025-all"), and getCurrentMonthPeriod() is checked to always start ^\d{4}-\d{2}-01T00:00:00\.000Z$. Without an explicit offset in the input, none of these would catch the drift — a test that runs in UTC CI would pass with a local-time implementation.
Takeaways
- A quota has one definition of "month." Put it in one function and make every count, display, and dedup key import it — divergence is only possible when the definition is duplicated.
- Prefer a half-open interval (
[start, end), end exclusive) for any time window. It has no gap, no overlap, and no "last millisecond" guesswork;Date.UTC(y, m + 1, 1)gives you the exclusive end and the year rollover in one expression. - Choose the timezone basis by purpose: UTC for aggregation and enforcement so the boundary is stable and machine-consistent; the user’s local calendar for display so a day looks like their day.
- Test the knife-edge on purpose — feed a fixed instant with a non-UTC offset that straddles the boundary, or the bug stays invisible until a real user hits it.
If you’re curious how this package is structured and versioned across web and mobile, see Designing a cross-platform subscription package. For how these limits are enforced per tenant, see Multi-tenant feature gating in Next.js.