Read OSS

Day.js Architecture: How 2KB Replaces Moment.js

Intermediate

Prerequisites

  • Basic JavaScript (ES modules, classes, prototypes)
  • Familiarity with the native JavaScript Date API (getFullYear, getTimezoneOffset, etc.)

Day.js Architecture: How 2KB Replaces Moment.js

Moment.js is one of the most downloaded npm packages in history, but it ships around 72KB minified and gzipped—and it mutates in place. Day.js set out to solve both problems: deliver the same API in under 3KB, and make every operation immutable. The result is a ~467-line core that delegates everything else to 37 opt-in plugins and 143 lazy-loaded locales. This article maps out how that works.

Project Overview and Design Philosophy

Day.js rests on three pillars:

  1. Micro-core: The entire Dayjs class, factory function, formatting engine, and date arithmetic fit in a single file under 470 lines.
  2. Opt-in plugins: Functionality like UTC mode, timezone support, relative time, and duration live in separate modules. You only pay for what you import.
  3. Immutability by default: Every manipulation method (add, subtract, set, startOf, endOf) returns a new Dayjs instance. The original is never modified.

The size constraint isn't aspirational—it's enforced in CI. The package.json configures size-limit to fail the build if dayjs.min.js exceeds 2.99KB:

package.json#L21-L26

"size-limit": [
  {
    "limit": "2.99 KB",
    "path": "dayjs.min.js"
  }
]

This budget shapes every design decision—from single-letter utility exports to the plugin system itself.

flowchart LR
    subgraph Core["Core (< 3KB)"]
        A[index.js<br/>Dayjs class + factory]
        B[constant.js<br/>Units + regex]
        C[utils.js<br/>6 micro-utilities]
    end
    subgraph Plugins["37 Plugins (opt-in)"]
        D[utc]
        E[timezone]
        F[duration]
        G[relativeTime]
        H[advancedFormat]
        I[...]
    end
    subgraph Locales["143 Locales (lazy)"]
        J[en]
        K[zh-cn]
        L[ja]
        M[...]
    end
    Core --> Plugins
    Core --> Locales

Directory Structure and File Roles

The repository is organized for maximum tree-shakeability. Each plugin and locale is a standalone module with dayjs as an external dependency:

Path Role
src/index.js Core library: Dayjs class, dayjs() factory, wrapper(), parseDate(), extend()
src/constant.js Time unit constants, unit name constants, REGEX_PARSE, REGEX_FORMAT
src/utils.js Six single-letter utility functions
src/locale/ 143 locale definition files, each self-registering
src/plugin/ 37 plugin directories, each with an index.js
build/ Rollup build orchestrator, config factory, ESM post-processor
types/ TypeScript declarations with plugin augmentation patterns
test/ Jest tests using Moment.js as oracle

Tip: The en locale is the only one bundled into the core (import en from './locale/en' on line 2 of index.js). Every other locale must be explicitly imported, keeping the default bundle minimal.

The dayjs() Factory and parseDate()

The public API starts with the dayjs() function. It's deceptively simple—just 10 lines—but it handles every input type a user might throw at it:

src/index.js#L39-L48

If the input is already a Dayjs instance, it clones it. Otherwise, it wraps the input in a config object and delegates to new Dayjs(cfg). The cfg.args = arguments line is important—plugins like customParseFormat inspect the second and third arguments to determine the format string and strict mode.

The actual Date conversion happens in parseDate():

src/index.js#L63-L83

flowchart TD
    Input["parseDate(cfg)"] --> N{date === null?}
    N -->|Yes| NaN["new Date(NaN)"]
    N -->|No| U{date === undefined?}
    U -->|Yes| Now["new Date() — now"]
    U -->|No| D{date instanceof Date?}
    D -->|Yes| Clone["new Date(date)"]
    D -->|No| S{typeof === 'string'<br/>AND not /Z$/i?}
    S -->|Yes| R{REGEX_PARSE match?}
    R -->|Yes| Construct["new Date(y, m, d, h, m, s, ms)<br/>or Date.UTC(...) if utc"]
    R -->|No| Native["new Date(date)"]
    S -->|No| Native

The critical subtlety is the /Z$/i test on line 68. If a string ends with Z (like 2024-01-01T00:00:00Z), Day.js skips its own regex parsing and hands the string directly to new Date(). This is deliberate: the browser's native parser handles ISO 8601 with timezone suffixes correctly, and re-implementing that logic would add bytes the library can't afford.

The REGEX_PARSE pattern itself is worth examining:

src/constant.js#L29

/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/

This handles YYYY, YYYY-MM, YYYY-MM-DD, and various time formats—but it only works for strings without timezone information. That's not a bug; it's a size optimization. Timezone parsing is deferred to the utc and timezone plugins.

The Dayjs Class: parse(), init(), and $-Prefixed State

The Dayjs class is the heart of the library. Its constructor does three things:

src/index.js#L85-L91

  1. Resolves the locale via parseLocale() and stores it as $L
  2. Calls this.parse(cfg), which plugins can override
  3. Initializes the $x extensions bag for plugin state

The parse() method calls parseDate() and then init(). The init() method extracts every time component from the native Date into $-prefixed instance properties:

src/index.js#L98-L108

classDiagram
    class Dayjs {
        +$d: Date
        +$y: number
        +$M: number
        +$D: number
        +$W: number
        +$H: number
        +$m: number
        +$s: number
        +$ms: number
        +$L: string
        +$x: object
        +$u: boolean
        +parse(cfg)
        +init()
        +$utils()
        +clone()
        +format(str)
        +add(n, unit)
        +startOf(unit)
        +$set(unit, value)
    }

Why cache these values instead of reading from $d each time? Performance. Property access on a plain object is faster than calling getFullYear(), getMonth(), etc., and the format engine reads these values repeatedly. The $ prefix is a convention signaling "internal, don't touch"—borrowed from AngularJS.

The $x bag deserves special attention. It's an empty object by default, but plugins use it to store per-instance state. The timezone plugin stores $x.$timezone, the UTC plugin propagates $x.$localOffset, and wrapper() preserves it across clones. This is the private state mechanism for the entire plugin ecosystem.

Immutability via wrapper() and clone()

Immutability is Day.js's second core promise, and it's enforced through two functions working in tandem:

src/index.js#L50-L56

The wrapper() function creates a new Dayjs instance while preserving the source instance's locale ($L), UTC mode ($u), extensions bag ($x), and fixed offset ($offset). It's used by clone():

clone() {
  return Utils.w(this.$d, this)
}

And by set():

set(string, int) {
  return this.clone().$set(string, int)
}
sequenceDiagram
    participant User
    participant Dayjs as dayjs instance
    participant Clone as new instance
    
    User->>Dayjs: .add(1, 'day')
    Dayjs->>Dayjs: calculate new timestamp
    Dayjs->>Clone: Utils.w(newTimestamp, this)
    Note over Clone: Inherits $L, $u, $x, $offset
    Clone-->>User: returns new Dayjs
    Note over Dayjs: Original unchanged

Notice the pattern: $set() mutates this (it's the private setter), but set() calls clone().$set(), ensuring the original instance is never modified. This split is deliberate—it allows the badMutable plugin to opt out of immutability by calling $set() directly, while the default behavior remains safe.

Tip: If you ever need mutable Day.js instances (e.g., in tight loops where GC pressure matters), the badMutable plugin exists exactly for that purpose. It overrides $g to call $set() instead of set(), skipping the clone.

Dynamic Getter/Setter Generation

Rather than hand-writing .year(), .month(), .date(), .hour(), .minute(), .second(), and .millisecond() methods, Day.js generates them in a loop:

src/index.js#L431-L446

const proto = Dayjs.prototype
dayjs.prototype = proto;
[
  ['$ms', C.MS],
  ['$s', C.S],
  ['$m', C.MIN],
  ['$H', C.H],
  ['$W', C.D],
  ['$M', C.M],
  ['$y', C.Y],
  ['$D', C.DATE]
].forEach((g) => {
  proto[g[1]] = function (input) {
    return this.$g(input, g[0], g[1])
  }
})

Each generated method delegates to $g(), which acts as a unified getter/setter:

$g(input, get, set) {
  if (Utils.u(input)) return this[get]
  return this.set(set, input)
}

The semicolon on line 432 (dayjs.prototype = proto;) is syntactically required. Without it, JavaScript would interpret the array literal on line 433 as a property access on the expression dayjs.prototype = proto, leading to a runtime error. It's a subtle gotcha of ASI (Automatic Semicolon Insertion) that bites even experienced developers.

Compact Utils and the Single-Letter Export Convention

The utils.js file exports six functions under single-letter names:

src/utils.js#L48-L55

Export Function Purpose
s padStart Left-pad strings (e.g., '5''05')
z padZoneStr Format UTC offset as +HH:MM
m monthDiff Fractional month difference (ported from Moment.js)
a absFloor Floor toward zero (not toward negative infinity)
p prettyUnit Normalize unit strings: 'Days''day', 'd''day'
u isUndefined s => s === undefined

The prettyUnit() function is particularly clever. It maps shorthand letters to full unit names (Mmonth, yyear, dday) and falls back to lowercasing and stripping trailing s:

src/utils.js#L30-L44

This means add(1, 'Days'), add(1, 'day'), and add(1, 'd') all resolve to the same unit—Moment.js compatibility without a lookup table for every variation.

The single-letter convention extends beyond utils.js. In index.js, the Utils object is augmented with three more functions:

Utils.l = parseLocale
Utils.i = isDayjs
Utils.w = wrapper

These are used extensively by plugins. The $utils() instance method gives plugins access to the whole bag, so plugin authors can write this.$utils().w(...) to create wrapper instances.

Tip: When reading Day.js plugin source code, keep a mental map: Utils.w = wrapper (clone with context), Utils.s = padStart, Utils.u = isUndefined, Utils.p = prettyUnit. You'll see these constantly.

What's Next

We've seen how Day.js packs a Moment.js-compatible core into under 3KB through aggressive minimalism: cached $-prefixed state, a single wrapper() function for immutability, dynamically generated getter/setters, and single-letter utility exports. But the core alone only handles basic parsing, formatting, and arithmetic.

In the next article, we'll dive into the plugin system—the mechanism that makes Day.js extensible without bloating the core. We'll see how dayjs.extend() installs plugins exactly once, how plugins wrap prototype methods to create middleware-like chains, and how a plugin as small as 8 lines can fundamentally change the library's behavior.