Summary
createContext() replaces the VM's global Date with a plain function
(packages/core/src/vm/index.ts). A plain function has no [[Construct]]
behavior that forwards new.target, so when user code does
class X extends Date, super() returns a new plain Date object which
then becomes this. The subclass instance loses its identity and all of its
own methods.
This silently breaks every library that models a date by subclassing Date.
The one that bit us is TZDate from
@date-fns/tz, which is the officially recommended way to do time-zone-aware
math with date-fns v4. Inside a workflow body, a TZDate degrades to a plain
Date, so every zone-aware getter (getFullYear, getMonth, getDate, …)
falls back to the container's time zone instead of the requested one.
The failure is silent: the underlying epoch value is still correct, only the
zone-aware accessors are wrong. Code that computes period boundaries or
formats a date key gets an off-by-one day whenever the container time zone
differs from the requested one — which, on a typical UTC container with a
non-UTC business time zone, is most of the day.
The docs currently say Date is safe to use in workflow functions
(docs/content/docs/v4/api-reference/workflow-globals.mdx) with no mention of
this caveat.
Reproduction
import { createContext, runInContext } from 'node:vm'
// Mirrors packages/core/src/vm/index.ts
const context = createContext()
const g = runInContext('globalThis', context)
const Date_ = g.Date
const fixedTimestamp = Date_.parse('2026-08-06T03:38:14.993Z')
g.Date = function Date(...args) {
if (args.length === 0) return new Date_(fixedTimestamp)
return new Date_(...args)
}
g.Date.prototype = Date_.prototype
Object.setPrototypeOf(g.Date, Date_)
g.Date.now = () => fixedTimestamp
console.log(runInContext(`
class Sub extends Date {
constructor(...args) { super(...args); this.tag = 'sub' }
label() { return 'sub' }
}
const sub = new Sub(2026, 6, 29)
JSON.stringify({
subIsSub: sub instanceof Sub,
subKeepsMethods: typeof sub.label === 'function',
subKeepsFields: sub.tag === 'sub',
})
`, context))
Actual
{"subIsSub":false,"subKeepsMethods":false,"subKeepsFields":false}
Expected
{"subIsSub":true,"subKeepsMethods":true,"subKeepsFields":true}
Real-world impact
With TZDate, inside a workflow body and a container running TZ=UTC:
// organization time zone is Asia/Tokyo
const d = new TZDate(2026, 6, 29, 'Asia/Tokyo') // 2026-07-29 00:00 JST
d.getDate() // => 28 (should be 29; read in UTC, not Asia/Tokyo)
Any validation of the form getFullYear()/getMonth()/getDate() round-tripping
a yyyy-mm-dd key therefore rejects perfectly valid dates, and any period
boundary (start of week/month/quarter) lands on the wrong day.
This does not reproduce on a developer machine whose system time zone happens
to match the business time zone, so it presents as a production-only bug.
Proposed fix
Use a class so new.target is preserved:
g.Date = class Date extends Date_ {
constructor(...args) {
if (args.length === 0) super(fixedTimestamp)
else super(...args)
}
}
g.Date.now = () => fixedTimestamp
Verified in a real node:vm context — determinism is unchanged and subclassing
is fixed:
current (function): {"nowIsFixed":true,"subIsSub":false,"subKeepsMethods":false}
proposed (class) : {"nowIsFixed":true,"subIsSub":true, "subKeepsMethods":true}
Both of the current fix-ups become unnecessary with the class form, because
extends already sets up the whole prototype chain:
g.Date.prototype = Date_.prototype — currently needed to make instanceof
work at all; with extends the real chain is in place, and the assignment is
illegal anyway (prototype is non-writable on a class).
Object.setPrototypeOf(g.Date, Date_) — currently needed to preserve the
statics; extends already makes Object.getPrototypeOf(g.Date) === Date_,
so Date.parse / Date.UTC are inherited. Only the Date.now override
stays as an own property.
Happy to open a PR if the approach looks right.
Environment
workflow 4.6.2 / @workflow/core 4.6.2 (also reproduced against the
current main — packages/core/src/vm/index.ts is unchanged)
- Node.js 24
- Next.js 16 App Router
Summary
createContext()replaces the VM's globalDatewith a plain function(
packages/core/src/vm/index.ts). A plain function has no[[Construct]]behavior that forwards
new.target, so when user code doesclass X extends Date,super()returns a new plain Date object whichthen becomes
this. The subclass instance loses its identity and all of itsown methods.
This silently breaks every library that models a date by subclassing
Date.The one that bit us is
TZDatefrom@date-fns/tz, which is the officially recommended way to do time-zone-awaremath with date-fns v4. Inside a workflow body, a
TZDatedegrades to a plainDate, so every zone-aware getter (getFullYear,getMonth,getDate, …)falls back to the container's time zone instead of the requested one.
The failure is silent: the underlying epoch value is still correct, only the
zone-aware accessors are wrong. Code that computes period boundaries or
formats a date key gets an off-by-one day whenever the container time zone
differs from the requested one — which, on a typical UTC container with a
non-UTC business time zone, is most of the day.
The docs currently say
Dateis safe to use in workflow functions(
docs/content/docs/v4/api-reference/workflow-globals.mdx) with no mention ofthis caveat.
Reproduction
Actual
{"subIsSub":false,"subKeepsMethods":false,"subKeepsFields":false}Expected
{"subIsSub":true,"subKeepsMethods":true,"subKeepsFields":true}Real-world impact
With
TZDate, inside a workflow body and a container runningTZ=UTC:Any validation of the form
getFullYear()/getMonth()/getDate()round-trippinga
yyyy-mm-ddkey therefore rejects perfectly valid dates, and any periodboundary (start of week/month/quarter) lands on the wrong day.
This does not reproduce on a developer machine whose system time zone happens
to match the business time zone, so it presents as a production-only bug.
Proposed fix
Use a class so
new.targetis preserved:Verified in a real
node:vmcontext — determinism is unchanged and subclassingis fixed:
Both of the current fix-ups become unnecessary with the class form, because
extendsalready sets up the whole prototype chain:g.Date.prototype = Date_.prototype— currently needed to makeinstanceofwork at all; with
extendsthe real chain is in place, and the assignment isillegal anyway (
prototypeis non-writable on a class).Object.setPrototypeOf(g.Date, Date_)— currently needed to preserve thestatics;
extendsalready makesObject.getPrototypeOf(g.Date) === Date_,so
Date.parse/Date.UTCare inherited. Only theDate.nowoverridestays as an own property.
Happy to open a PR if the approach looks right.
Environment
workflow4.6.2 /@workflow/core4.6.2 (also reproduced against thecurrent
main—packages/core/src/vm/index.tsis unchanged)