Moment.js isBetween(): Practical Date-Range Checks in JavaScript

I run into date-range checks everywhere: feature rollouts (“show this banner between March 1 and March 15”), booking rules (“check-in must be within 48 hours”), compliance windows, subscription trials, even rate-limit cooldowns. The bug pattern is always the same: someone compares strings, someone mixes time zones, someone forgets whether the endpoints are inclusive, and a customer finds the edge case at 11:59 PM on the last day.

Moment.js has a small but very practical tool for this exact job: isBetween(). I still see it in plenty of production systems in 2026 because older codebases don’t rewrite their date logic every time the JavaScript ecosystem shifts. If you maintain one of those systems, you don’t need a theory lesson—you need a clear mental model of what isBetween() checks, how boundary rules work, and how to avoid the timezone and parsing traps.

I’ll walk you through isBetween() from the ground up: the signature, the “moment-like” inputs it accepts, inclusivity controls, unit-based comparisons, and the production patterns I rely on when correctness matters more than cleverness.

Installing Moment and running a script

If you’re working in Node.js, the simplest setup is still a tiny script with Moment installed from npm.

1) Install:

npm install moment

2) Check the installed version:

npm version moment

3) Create index.js and run it:

node index.js

A minimal runnable file looks like this:

// index.js

const moment = require(‘moment‘);

const now = moment();

console.log(‘Now:‘, now.toISOString());

console.log(‘Valid moment?‘, now.isValid());

If you’re on an ESM-only project, Moment can be awkward because a lot of real-world setups still treat it as CommonJS. When I’m stuck on ESM, I usually keep a small compatibility layer (or I migrate date handling altogether). For this post, I’m going to use require() so the examples run in the widest set of environments.

One more practical setup tip: for reproducible results, I like to print the runtime timezone and a sample offset. It helps when you’re sharing output with a teammate in another region.

const moment = require(‘moment‘);

console.log(‘System offset (minutes):‘, moment().utcOffset());

console.log(‘Local now:‘, moment().format());

console.log(‘UTC now:‘, moment.utc().format());

That single utcOffset() line often explains “why it worked on my laptop.”

Understanding isBetween(): signature, inputs, return

The core idea is simple: you call isBetween() on a moment, and you pass two “bounds” representing the start and end of a range.

Moment documents these call shapes:

moment().isBetween(momentLikeA, momentLikeB);

moment().isBetween(momentLikeA, momentLikeB, unit);

moment().isBetween(momentLikeA, momentLikeB, unit, inclusivity);

  • momentLikeA and momentLikeB can be many types: Moment String

    Number

    Date

    Array.

String: typically an ISO-8601 string like ‘2010-10-20‘ or ‘2026-02-04T15:30:00Z‘.

Number: usually a Unix timestamp in milliseconds.

Date: a JavaScript Date instance.

Array: Moment’s [year, month, day, hour, minute, second, millisecond] form (month is 0-based there).

  • unit is a string like ‘minute‘, ‘hour‘, ‘day‘, ‘month‘, etc.
  • inclusivity is a 2-character string controlling whether endpoints count:

‘()‘ excludes both ends (this is the default behavior).

‘[]‘ includes both ends.

‘(]‘ excludes start, includes end.

‘[)‘ includes start, excludes end.

Return value is a boolean: true if the moment you called it on is within the range (based on the chosen unit and inclusivity rules), otherwise false.

One thing I want you to internalize: most “weird” results come from boundary expectations (exclusive vs inclusive), unit granularity, or mixed time zones—not from Moment doing random things.

“Moment-like” input doesn’t mean “safe input”

In production, the most dangerous part of isBetween() isn’t the comparison—it’s the parsing.

Moment will accept a lot of inputs and try to “do what I mean.” That’s great for a quick script and risky for validation logic.

Here’s how I think about inputs:

  • Safe inputs: already a moment you created intentionally, ISO strings with offsets (Z or +/-HH:mm), Date objects created from trusted sources, timestamps in milliseconds.
  • Risky inputs: locale-formatted dates (‘02/04/2026‘), date-only strings where timezone matters (‘2026-02-04‘), strings without offsets that might be interpreted differently on different machines.

If you treat parsing as a separate step from comparison, your range logic becomes dramatically easier to trust.

A quick mental model

When I read target.isBetween(start, end, unit, inclusivity), I translate it to:

  • Convert target, start, end into moments (if they aren’t already).
  • Optionally compare at a granularity (unit).
  • Determine whether the endpoints are allowed (based on inclusivity).
  • Return true/false.

That’s it. If the outcome surprises you, you almost always have one of these problems:

1) You accidentally wanted inclusive endpoints.

2) You accidentally used unit granularity.

3) You accidentally mixed local and UTC.

4) You accidentally parsed a string differently than you assumed.

Boundary semantics: exclusivity, inclusivity, and reversed ranges

By default, isBetween(a, b) is exclusive on both ends. That means a value exactly equal to a or exactly equal to b returns false unless you change the inclusivity.

Here’s a runnable example that mirrors the classic behavior people test first:

// index.js

const moment = require(‘moment‘);

const within = moment(‘2010-10-20‘).isBetween(‘2010-10-19‘, ‘2010-10-25‘);

console.log(within); // true

const reversed = moment(‘2010-10-20‘).isBetween(‘2010-10-25‘, ‘2010-10-19‘);

console.log(reversed); // false

Notice the second check: the order matters. Moment does not automatically sort your bounds. In production code, I treat “maybe swapped bounds” as a data quality problem and decide explicitly what to do:

  • If swapped bounds indicate a bug, I fail fast (throw, or log + return false).
  • If swapped bounds are expected (for example, user input), I normalize by swapping.

Here’s a small helper that normalizes while keeping the meaning clear:

const moment = require(‘moment‘);

function isBetweenNormalized(value, a, b, unit = null, inclusivity = ‘()‘) {

const left = moment(a);

const right = moment(b);

const target = moment(value);

if (!left.isValid() |

!right.isValid()

!target.isValid()) return false;

const start = left.isBefore(right) ? left : right;

const end = left.isBefore(right) ? right : left;

return unit

? target.isBetween(start, end, unit, inclusivity)

: target.isBetween(start, end, undefined, inclusivity);

}

console.log(isBetweenNormalized(‘2010-10-20‘, ‘2010-10-25‘, ‘2010-10-19‘)); // true

Now let’s talk about inclusivity, because it decides whether “midnight on the first day” counts.

const moment = require(‘moment‘);

const start = ‘2026-02-01T00:00:00Z‘;

const end = ‘2026-02-04T00:00:00Z‘;

console.log(moment(start).isBetween(start, end)); // false (exclusive)

console.log(moment(start).isBetween(start, end, null, ‘[)‘)); // true (includes start)

console.log(moment(end).isBetween(start, end, null, ‘[)‘)); // false (excludes end)

console.log(moment(end).isBetween(start, end, null, ‘[]‘)); // true (includes end)

In my experience, "[)" is the most useful default for “time windows”:

  • Include the start instant (the window begins now).
  • Exclude the end instant (avoids double-counting if windows abut).

If you’ve ever dealt with billing periods, analytics buckets, or rate-limit windows, you’ve already felt why "[)" prevents off-by-one style overlap.

Picking inclusivity based on intent (my rule of thumb)

When I see a range, I ask a very specific question: is it a “window of time” or is it “two endpoints that are allowed”?

  • Most time windows should be half-open: [start, end).
  • Most “valid between these two dates inclusive” rules are closed: [start, end].

A tiny cheat table I use:

Rule type

Typical domain

Inclusivity I default to —

— Time window

cooldowns, rate limits, trials

[) Calendar dates inclusive

promos, policy dates

[] (often with ‘day‘) Strictly inside

“must be after X and before Y”

()

Reversed ranges: normalize, reject, or interpret?

It’s tempting to normalize swapped bounds automatically everywhere. I don’t.

I normalize only when:

  • The bounds are user input and users might reasonably swap them.
  • The range is symmetrical and my domain logic doesn’t care.

I reject (return false / error) when:

  • The bounds define a policy or configuration (feature flag windows, billing logic, compliance). A swapped bound is a misconfiguration, and “fixing it automatically” can hide a problem.

A simple policy check:

function isValidRangeUtc(startIso, endIso) {

const s = moment.utc(startIso);

const e = moment.utc(endIso);

return s.isValid() && e.isValid() && !e.isBefore(s);

}

If that returns false, I’d rather alarm early than silently change behavior.

Unit-aware comparisons: minutes, hours, days, and rounding expectations

isBetween() can compare at a unit scale. This is where people unintentionally switch from “precise instants” to “calendar-ish” logic.

When you pass a unit like ‘day‘, Moment compares using that unit’s boundaries. The easiest way to understand it is to test values that are close to the boundary.

const moment = require(‘moment‘);

const value = moment(‘2026-02-04T23:59:59‘);

const start = moment(‘2026-02-04T00:00:00‘);

const end = moment(‘2026-02-05T00:00:00‘);

// Millisecond-precise (default)

console.log(value.isBetween(start, end, null, ‘[)‘)); // true

// Day-granularity: compares by day buckets

console.log(value.isBetween(start, end, ‘day‘, ‘[)‘));

If that surprised you, good—you just found the difference between these two mental models:

  • Instant model: “Is the timestamp between two timestamps?”
  • Bucket model: “Is this in a calendar bucket between two buckets?”

I recommend you only pass a unit when you genuinely want bucket behavior. Common cases where unit granularity is correct:

  • “Only allow changes within the same day.”
  • “This metric applies between these months.”
  • “Treat anything on the same week as equivalent.”

Common cases where unit granularity is a trap:

  • Session timeouts (should be instant-based).
  • Security expiry checks (instant-based, and in UTC).
  • Booking cutoffs measured in hours or minutes (instant-based).

If you want “calendar-day inclusive” checks, be explicit. I often write it as “day window” logic so nobody confuses it with instant ranges:

const moment = require(‘moment‘);

function isOnOrAfterDay(value, day) {

const v = moment(value);

const d = moment(day);

if (!v.isValid() || !d.isValid()) return false;

return v.isSameOrAfter(d, ‘day‘);

}

function isBeforeDay(value, day) {

const v = moment(value);

const d = moment(day);

if (!v.isValid() || !d.isValid()) return false;

return v.isBefore(d, ‘day‘);

}

// Equivalent to a [startOfDay, startOfNextDay) check

function isWithinCalendarDay(value, day) {

return isOnOrAfterDay(value, day) && isBeforeDay(value, moment(day).add(1, ‘day‘));

}

console.log(isWithinCalendarDay(‘2026-02-04T23:59:59‘, ‘2026-02-04‘)); // true

That style is more verbose than a single isBetween() call, but it’s also harder to misunderstand during code review.

Units you can use (and what I watch for)

Moment supports a variety of unit strings: ‘year‘, ‘month‘, ‘week‘, ‘day‘, ‘hour‘, ‘minute‘, ‘second‘, plus plural versions.

The units that cause the most “wait, what?” moments in real apps:

  • ‘week‘: “week” depends on locale settings (first day of week), which can shift boundaries.
  • ‘month‘: month boundaries change length; if you’re doing “within N months,” be careful about what “month” means.
  • ‘day‘: day boundaries are timezone-dependent; midnight differs by region.

If you’re doing policy checks, I prefer ‘hour‘/‘minute‘ in UTC for anything time-sensitive.

Time zones, DST, and parsing discipline

Most date-range bugs I fix aren’t about isBetween() itself—they’re about two timestamps that look similar but live in different time zones.

Three rules I follow:

1) If the business rule is global, store and compare in UTC.

2) If the business rule is local to a region (store hours, local deadlines), use an explicit IANA zone and keep it consistent.

3) Never rely on “Date string parsing” that depends on the runtime’s locale quirks.

Local vs UTC comparisons

Moment has moment() (local) and moment.utc() (UTC). Mixing them creates silent misinterpretations.

const moment = require(‘moment‘);

const aUtc = moment.utc(‘2026-02-04T12:00:00Z‘);

const bLocal = moment(‘2026-02-04T12:00:00‘); // interpreted as local time

console.log(aUtc.format());

console.log(bLocal.format());

// These may represent different instants depending on your machine timezone.

console.log(aUtc.valueOf() === bLocal.valueOf());

If you want consistent instant comparisons, I recommend converting everything to UTC before comparing:

const moment = require(‘moment‘);

function toUtcMoment(input) {

const m = moment.isMoment(input) ? input.clone() : moment(input);

return m.isValid() ? m.utc() : moment.invalid();

}

function isBetweenUtc(value, start, end, inclusivity = ‘[)‘) {

const v = toUtcMoment(value);

const s = toUtcMoment(start);

const e = toUtcMoment(end);

if (!v.isValid() |

!s.isValid()

!e.isValid()) return false;

return v.isBetween(s, e, null, inclusivity);

}

console.log(isBetweenUtc(‘2026-02-04T12:00:00Z‘, ‘2026-02-04T00:00:00Z‘, ‘2026-02-05T00:00:00Z‘));

DST traps

Daylight Saving Time transitions create “missing” or “duplicated” local times. If you compare by local clock without a zone-aware library, you can accidentally accept a time that never occurs.

If your app has regional rules (like “between 9:00 and 17:00 New York time”), I prefer using moment-timezone and explicit zones. isBetween() will then behave predictably because all moments are anchored to the same zone.

Even if you don’t pull in timezone tooling, the key practice is: define the domain time zone and apply it consistently. Don’t let your production servers decide it for you.

A pattern I’ve used in scheduling code:

  • Store instants in UTC (startAtUtc, endAtUtc).
  • Store the “display zone” separately (‘America/New_York‘).
  • Only apply the zone when rendering or when interpreting a local business rule.

The mistake I see is trying to store only local time without the zone and “rebuild” the instant later. That’s where DST bugs are born.

Strict parsing

If you feed Moment ambiguous strings, it makes a best effort guess. That’s convenient, but in validation logic it’s risky.

For external inputs, I prefer strict parsing with a specified format:

const moment = require(‘moment‘);

function parseIsoDateStrict(dateString) {

// ISO 8601 strict

const m = moment(dateString, moment.ISO_8601, true);

return m.isValid() ? m : moment.invalid();

}

const good = parseIsoDateStrict(‘2026-02-04T10:20:30Z‘);

const bad = parseIsoDateStrict(‘02/04/2026‘);

console.log(good.isValid()); // true

console.log(bad.isValid()); // false

Strict parsing keeps your isBetween() checks honest. If your input parsing is sloppy, your range check is only as correct as the guess.

Date-only strings: the sneaky source of off-by-one

Date-only strings like ‘2026-02-04‘ are tricky because they often represent a business date, not a timestamp. If you parse them in local time, midnight is local midnight; if you treat them as UTC, midnight is UTC midnight.

My approach:

  • If the input is meant to be a calendar date, parse it with a known format (‘YYYY-MM-DD‘) and use day granularity.
  • If the input is meant to be an instant, require an offset (Z or +/-HH:mm).

When a system mixes these two, you end up with “it’s Feb 4 here but Feb 5 in UTC” bugs. That’s not isBetween() failing—it’s the domain model being unclear.

Production patterns: validations, windows, and feature flags

I like to treat isBetween() as a building block. The best results come from wrapping it in domain language so the calling code reads like a policy.

1) Booking window: 48 hours from now

A common requirement: “You can reschedule only within the next 48 hours.” That’s an instant-based window, and I strongly recommend UTC.

const moment = require(‘moment‘);

function canReschedule(appointmentStartIso, nowIso = moment.utc().toISOString()) {

const now = moment.utc(nowIso);

const start = moment.utc(appointmentStartIso);

if (!now.isValid() || !start.isValid()) return false;

const windowStart = now; // inclusive

const windowEnd = now.clone().add(48, ‘hours‘); // exclusive

return start.isBetween(windowStart, windowEnd, null, ‘[)‘);

}

console.log(canReschedule(‘2026-02-05T10:00:00Z‘));

Notice what I’m doing:

  • Using UTC for everything.
  • Using "[)" so “exactly now” counts, “exactly at +48h” does not.
  • Cloning when adding time (Moment mutates).

2) Feature flag rollout window (date-only policy)

Sometimes the rule is calendar-based: “Enable this promo from Feb 1 through Feb 7, inclusive.”

I implement that as a date range on local business dates, not instants. If the business is global, use UTC dates; if regional, use that region.

const moment = require(‘moment‘);

function isPromoActive(businessDate, startDate, endDateInclusive) {

const d = moment(businessDate, ‘YYYY-MM-DD‘, true);

const s = moment(startDate, ‘YYYY-MM-DD‘, true);

const e = moment(endDateInclusive, ‘YYYY-MM-DD‘, true);

if (!d.isValid() |

!s.isValid()

!e.isValid()) return false;

// Compare at day granularity, inclusive on both ends.

return d.isBetween(s, e, ‘day‘, ‘[]‘);

}

console.log(isPromoActive(‘2026-02-04‘, ‘2026-02-01‘, ‘2026-02-07‘)); // true

Here, ‘day‘ is appropriate because the domain is a “business date”, not a timestamp.

3) Request cooldown: reject if within last N minutes

For rate limiting, I keep everything in milliseconds and treat the window as half-open.

const moment = require(‘moment‘);

function isInCooldown(lastActionIso, nowIso, minutes) {

const last = moment.utc(lastActionIso);

const now = moment.utc(nowIso);

if (!last.isValid() || !now.isValid()) return false;

const start = last;

const end = last.clone().add(minutes, ‘minutes‘);

// If now is between [lastAction, lastAction+minutes), we are still cooling down.

return now.isBetween(start, end, null, ‘[)‘);

}

console.log(isInCooldown(‘2026-02-04T12:00:00Z‘, ‘2026-02-04T12:04:59Z‘, 5)); // true

4) A reusable checker (clean calling code)

This is the pattern I ship most often: a small function that normalizes inputs, enforces UTC, and makes inclusivity explicit.

const moment = require(‘moment‘);

function isBetweenWindowUtc(value, start, end, { inclusivity = ‘[)‘ } = {}) {

const v = moment.utc(value);

const s = moment.utc(start);

const e = moment.utc(end);

if (!v.isValid() |

!s.isValid()

!e.isValid()) return false;

if (e.isBefore(s)) return false; // treat swapped bounds as invalid policy

return v.isBetween(s, e, null, inclusivity);

}

console.log(

isBetweenWindowUtc(

‘2026-02-04T12:00:00Z‘,

‘2026-02-04T00:00:00Z‘,

‘2026-02-05T00:00:00Z‘

)

);

In code review, this kind of wrapper saves time because the “what does it mean?” questions get answered once, centrally.

The two comparisons I rely on most: instant windows vs calendar windows

When I’m reviewing code that uses isBetween(), I try to classify it into one of two categories. If it doesn’t clearly fit, that’s a smell.

Instant windows (recommended default for time-based rules)

Definition: a window is two instants, usually in UTC.

Examples:

  • Session expiration
  • Token validity
  • “Cancel within 2 hours”
  • Trial period measured in hours or days from signup instant

Implementation pattern:

  • Use moment.utc() everywhere.
  • Use [).
  • Avoid unit unless there’s a strong reason.

Calendar windows (recommended for business dates)

Definition: a window is defined by calendar boundaries (days/months) in a chosen timezone.

Examples:

  • Promotions “Feb 1–Feb 7 inclusive”
  • Reporting “this month”
  • Scheduling “within the same business day”

Implementation pattern:

  • Parse dates in a strict calendar format.
  • Use unit: ‘day‘ or ‘month‘ intentionally.
  • Use [] when the business expects inclusive dates.

This distinction prevents a ton of “why did it fail right at midnight?” issues.

Mutability and cloning: the silent source of range bugs

Moment instances are mutable. That means operations like .add(), .subtract(), .startOf(), .endOf() modify the moment in place.

This matters for isBetween() because you often compute a boundary from another boundary.

If you do this:

const start = moment.utc(‘2026-02-04T00:00:00Z‘);

const end = start.add(1, ‘day‘);

You didn’t create “end = start + 1 day.” You mutated start too. Now start and end refer to the same changed instance.

The safe version:

const start = moment.utc(‘2026-02-04T00:00:00Z‘);

const end = start.clone().add(1, ‘day‘);

My habit: if a moment is used as an input boundary, I treat it as immutable and clone before modifying.

Edge cases I intentionally test for

I don’t consider date-range logic “done” until I’ve tested a few sharp edges. These are the ones that actually bite customers.

1) Exactly at the start

If policy says start is allowed, assert it.

const s = moment.utc(‘2026-02-01T00:00:00Z‘);

const e = moment.utc(‘2026-02-02T00:00:00Z‘);

console.log(s.isBetween(s, e, null, ‘[)‘)); // should be true

2) Exactly at the end

If policy says end is not allowed, assert it.

console.log(e.isBetween(s, e, null, ‘[)‘)); // should be false

3) One millisecond before end

This is the classic boundary off-by-one.

const nearEnd = e.clone().subtract(1, ‘millisecond‘);

console.log(nearEnd.isBetween(s, e, null, ‘[)‘)); // should be true

4) Invalid inputs

If any input is invalid, I return false (or throw) deliberately—never “best guess.”

console.log(moment(‘not-a-date‘).isValid());

5) Swapped bounds

I decide policy: normalize or reject. Then I test that explicitly.

Debugging an unexpected result (my checklist)

When isBetween() gives me a result I didn’t expect, I don’t stare at the boolean. I print out the facts.

I usually log these fields (in a sanitized way if it’s production):

  • target.format() and target.toISOString()
  • start.format() and start.toISOString()
  • end.format() and end.toISOString()
  • target.valueOf(), start.valueOf(), end.valueOf() (milliseconds)
  • Which unit and inclusivity were used
  • The runtime timezone offset (moment().utcOffset())

A quick debug helper:

function describe(m) {

return {

isValid: m.isValid(),

format: m.isValid() ? m.format() : null,

iso: m.isValid() ? m.toISOString() : null,

valueOf: m.isValid() ? m.valueOf() : null,

offset: m.isValid() ? m.utcOffset() : null,

};

}

const target = moment(‘2026-02-04T12:00:00‘);

const start = moment.utc(‘2026-02-04T00:00:00Z‘);

const end = moment.utc(‘2026-02-05T00:00:00Z‘);

console.log({ target: describe(target), start: describe(start), end: describe(end) });

The moment you see offsets differ (or toISOString() changes meaning), you’ve found the root cause.

Practical scenarios (more patterns I use)

The earlier examples cover the basics. Here are a few more real-world scenarios where isBetween() is handy, plus the details that keep them correct.

1) “Active subscription trial” window

Trials are almost always instant-based: start at signup instant, end at signup + N days/hours.

Key detail: define whether “expires at the same instant” counts.

I default to [) so the trial ends cleanly and doesn’t overlap with paid status.

const moment = require(‘moment‘);

function isTrialActive({ signupAtUtcIso, trialDays }, nowUtcIso) {

const signup = moment.utc(signupAtUtcIso);

const now = moment.utc(nowUtcIso);

if (!signup.isValid() || !now.isValid()) return false;

const end = signup.clone().add(trialDays, ‘days‘);

return now.isBetween(signup, end, null, ‘[)‘);

}

2) “Business hours” checks

This is where I see developers reach for isBetween() incorrectly.

If the rule is “between 09:00 and 17:00 local time,” the window repeats every day, and the date portion matters.

A safe approach is:

  • Interpret the instant in the business timezone.
  • Construct that day’s opening and closing instants.
  • Compare [open, close).

Conceptually:

// Pseudocode-ish without timezone library details:

// localInstant = convert utcInstant to business timezone

// open = localInstant.clone().startOf(‘day‘).add(9, ‘hours‘)

// close = localInstant.clone().startOf(‘day‘).add(17, ‘hours‘)

// return localInstant.isBetween(open, close, null, ‘[)‘)

If you skip the timezone step, you’ll accept or reject users based on where your server is running.

3) “Compliance window” with strict UTC

Compliance policies tend to be global and audit-sensitive. I go strict:

  • Inputs must be ISO with Z.
  • Everything compared in UTC.
  • Swapped bounds rejected.
  • Invalid inputs rejected.

In those cases, a wrapper like isBetweenWindowUtc() is not just convenience—it’s a correctness boundary.

4) “Overlap detection” between two ranges

isBetween() checks a point against a range. Sometimes you need to know if two ranges overlap.

A reliable overlap check for half-open windows [aStart, aEnd) and [bStart, bEnd) is:

  • They overlap if aStart < bEnd AND bStart < aEnd.

Using Moment helpers:

function rangesOverlapUtc(aStartIso, aEndIso, bStartIso, bEndIso) {

const aStart = moment.utc(aStartIso);

const aEnd = moment.utc(aEndIso);

const bStart = moment.utc(bStartIso);

const bEnd = moment.utc(bEndIso);

if (![aStart, aEnd, bStart, bEnd].every(m => m.isValid())) return false;

if (aEnd.isSameOrBefore(aStart) || bEnd.isSameOrBefore(bStart)) return false;

return aStart.isBefore(bEnd) && bStart.isBefore(aEnd);

}

I include this because developers sometimes try to do overlap checks with isBetween() in a way that misses edge cases (especially “touching” ranges). Half-open windows make overlap logic crisp.

Performance considerations (practical, not theoretical)

Moment is not the lightest library, and in hot paths you can feel it. But performance issues usually come from how you use it, not from isBetween() itself.

What I watch for:

  • Re-parsing the same boundary many times in loops.
  • Creating lots of moments when timestamps would do.
  • Parsing ambiguous strings that trigger heavier parsing logic.

1) Pre-parse your boundaries

If you’re checking thousands of events against one window (say, filtering logs for a dashboard), parse the window once.

const start = moment.utc(‘2026-02-01T00:00:00Z‘);

const end = moment.utc(‘2026-02-02T00:00:00Z‘);

function isInWindow(iso) {

const t = moment.utc(iso);

return t.isValid() && t.isBetween(start, end, null, ‘[)‘);

}

2) Use numeric timestamps when it’s purely instant math

If your data is already milliseconds, you can avoid Moment in the hot loop.

Half-open window check for numbers:

function isBetweenMs(valueMs, startMs, endMs) {

return valueMs >= startMs && valueMs < endMs;

}

I still keep isBetween() around for readability at boundaries (where humans configure times), but for bulk filtering, numeric comparisons can be dramatically simpler.

3) Avoid unit granularity unless needed

Unit granularity can do additional internal steps (think “startOf unit” style semantics). If you don’t need it, skip it.

My default: use precise instants and [).

When I avoid isBetween() entirely

I like isBetween(), but I don’t use it as a hammer.

1) When the domain wants overlap/containment

If you’re comparing two ranges, use range overlap logic, not a point check.

2) When I only need a one-sided check

If you only need “after start” or “before end,” the intent is clearer with:

  • isSameOrAfter(start)
  • isBefore(end)

Half-open window pattern without isBetween():

function isInHalfOpenWindowUtc(valueIso, startIso, endIso) {

const v = moment.utc(valueIso);

const s = moment.utc(startIso);

const e = moment.utc(endIso);

if (![v, s, e].every(m => m.isValid())) return false;

return v.isSameOrAfter(s) && v.isBefore(e);

}

Sometimes I prefer this because it makes the half-open semantics obvious even to someone who has never seen ‘[)‘.

3) When parsing rules are complex

If you need strict timezone interpretation, locale-aware business calendars, or “next business day” logic, isBetween() isn’t the hard part—the calendar model is. At that point, I reach for more explicit modeling and usually a timezone-aware setup.

Alternatives and migration notes (without rewriting the world)

Even if you’re sticking with Moment in an existing system, it helps to understand the shape of alternatives because it clarifies what you’re relying on.

  • If you want immutable date-time objects by default, many modern libraries push you that way.
  • If you want explicit time zones as first-class values, libraries built around IANA zones can feel more natural.

That said, if you’re maintaining a large Moment-based codebase, the biggest win often isn’t switching libraries. It’s standardizing:

  • Input formats
  • UTC vs local rules
  • Inclusivity policy ([) vs [])
  • Strict parsing for external inputs

I’ve seen teams “modernize” by swapping libraries and still keep the same conceptual bugs. The conceptual model is the real upgrade.

A small “policy wrapper” I keep around

If I’m doing anything policy-like (feature flags, compliance windows, billing), I keep a wrapper that encodes my house rules:

  • All instants are UTC.
  • Inputs must be valid.
  • Bounds must be in order.
  • Inclusivity defaults to [).

const moment = require(‘moment‘);

function parseUtcIsoStrict(isoString) {

const m = moment(isoString, moment.ISO_8601, true);

return m.isValid() ? m.utc() : moment.invalid();

}

function isWithinPolicyWindowUtc(valueIso, startIso, endIso, { inclusivity = ‘[)‘ } = {}) {

const value = parseUtcIsoStrict(valueIso);

const start = parseUtcIsoStrict(startIso);

const end = parseUtcIsoStrict(endIso);

if (![value, start, end].every(m => m.isValid())) return false;

if (end.isBefore(start)) return false;

return value.isBetween(start, end, null, inclusivity);

}

The moment I introduce this, the calling code becomes boring—and boring is exactly what I want for time logic.

Quick reference: my default choices

If you want a practical default that avoids most production bugs, here’s what I’d standardize:

  • Use UTC for instant-based checks: moment.utc(...).
  • Require offsets for instants: ...Z or +/-HH:mm.
  • Use half-open windows for time windows: [).
  • Avoid unit unless you truly want calendar buckets.
  • Strict-parse untrusted inputs.
  • Clone boundaries before mutating.

A tiny decision table:

You’re trying to answer…

Recommended approach

Is this instant within a window?

isBetween(..., null, ‘[)‘) in UTC

Is this date between two dates inclusive?

isBetween(..., ‘day‘, ‘[]‘) with strict ‘YYYY-MM-DD‘ parsing

Do two windows overlap?

numeric/isBefore overlap check (not isBetween)

Do I allow exactly-at-end?

Use [] or compare with isSameOrBefore## Final thoughts
isBetween() is one of those deceptively small APIs that carries a lot of semantic weight: inclusivity rules, unit granularity, time zones, and parsing assumptions all meet at the same line of code.

When it’s used with clear intent—especially with strict parsing and a consistent UTC policy—it becomes a reliable foundation for the kinds of date-range rules that make or break real user workflows. When it’s used casually, it turns into a magnet for edge cases.

If you take only one thing from this: pick an inclusivity policy (I default to [)), pick a time zone policy (I default to UTC for instants), and enforce strict parsing at your boundaries. After that, isBetween() becomes the simple tool it was meant to be.

Scroll to Top