Check if Two Date Ranges Overlap in JavaScript

To test whether two half-open date ranges overlap, check aStart < bEnd && bStart < aEnd — true only when they share at least one day. Part of Working with Date Ranges and Intervals.

Why this scenario is tricky

The overlap test people reach for first — checking whether either range's start falls inside the other — misses the case where one range fully contains the other, and it double-checks the case where they merely touch. The correct condition is famously terse but easy to get backwards: two half-open ranges [aStart, aEnd) and [bStart, bEnd) overlap exactly when aStart < bEnd and bStart < aEnd. The strict < (not <=) is what makes touching ranges — where one ends exactly as the other begins — count as not overlapping, which is almost always what a scheduler wants.

The cleanest way to convince yourself the condition is right is to derive it from its negation. Two ranges fail to overlap in exactly two ways: A is entirely before B (aEnd <= bStart), or A is entirely after B (bEnd <= aStart). Overlap is simply "neither of those," and applying De Morgan's law to NOT (aEnd <= bStart OR bEnd <= aStart) gives aEnd > bStart AND bEnd > aStart, which rearranges to aStart < bEnd AND bStart < aEnd. That derivation is worth keeping in a comment, because the two-comparison form looks arbitrary until you see it is just the complement of "disjoint," and the complement is what makes it total: it is correct whether the ranges are disjoint, touching, partially overlapping, nested, or identical, with no special cases.

Contrast that with the tempting shortcut of asking "is A's start inside B, or B's start inside A?" It looks reasonable and handles partial overlaps, but it silently fails when one range is wholly contained in the other without either start landing inside the sibling in the way you checked, and it needs extra clauses to cover every nesting. Every extra clause is another chance to get a boundary wrong. The two-comparison rule needs none, which is exactly why it is the one to memorise.

This matters constantly in production. A booking system rejects a reservation that overlaps an existing one but must allow a check-in on the same day as a previous guest's check-out — that is the touching case, and only the strict-< half-open form gets it right. A calendar sync detects conflicting events; a feature-flag service checks whether two rollout windows collide; a shift planner prevents double-booking a nurse. In each, "overlap" means "share at least one unit of time," and the half-open rule expresses that precisely.

One condition covers every arrangementChecking 'is a start inside b?' misses containmentOne condition covers every arrangementaStart in b OR bStart in amisses full containmentaStart < bEnd && bStart < aEndhandles every caseThe two-comparison rule is total; the 'start inside' shortcut has blind spots.

Minimal working solution

Compare the endpoints with Temporal.PlainDate.compare. Touching ranges do not overlap under the half-open convention.

import { Temporal } from '@js-temporal/polyfill';
const lt = (x: Temporal.PlainDate, y: Temporal.PlainDate) => Temporal.PlainDate.compare(x, y) < 0;

function overlaps(aS, aE, bS, bE: Temporal.PlainDate) {
  // Overlap iff a starts before b ends AND b starts before a ends.
  return lt(aS, bE) && lt(bS, aE);
}

The lt helper exists only to make the intent legible: Temporal.PlainDate.compare(x, y) < 0 reads as "x is strictly before y," so lt(aS, bE) is "A starts before B ends." Wrapping it keeps the overlap expression free of raw compare(...) < 0 noise, which is where sign mistakes creep in. Because PlainDate has no time-of-day, there is no risk that a stray 09:00 on one endpoint tips a comparison — the values are pure calendar dates, so the result is the same on every machine in every zone.

If your product treats touching ranges as a conflict — some resource schedulers want a buffer so a room is not booked back-to-back — switch the two comparisons to <=. That single change turns the half-open rule into the closed-interval rule, and making it a named parameter (inclusiveEnd: boolean) documents the choice instead of burying it. The important thing is to decide deliberately rather than discover the behaviour from a bug report about a double-booked room.

When the ranges are genuinely instants rather than civil dates — an on-call shift measured to the minute — swap Temporal.PlainDate.compare for Temporal.Instant.compare or Temporal.ZonedDateTime.compare. The overlap expression is identical; only the comparator changes, because the rule is a property of any total order, not of dates specifically.

The overlap conditionTwo strict comparisons decide itThe overlap conditiona: [aS,aE)b: [bS,bE)aS < bE&& bS < aEoverlap?true/false

Full production version

A production helper validates each range, returns whether they overlap, and optionally the overlapping sub-range (useful for merging calendars). Computing the intersection is strictly more useful than a boolean: a null result is the "no overlap" answer, and a non-null result hands you the shared window for free, so one function serves both "do they conflict?" and "by how much?"

import { Temporal } from '@js-temporal/polyfill';
type R = { start: Temporal.PlainDate; end: Temporal.PlainDate };
const cmp = Temporal.PlainDate.compare;

function intersection(a: R, b: R): R | null {
  if (cmp(a.start, a.end) > 0 || cmp(b.start, b.end) > 0) throw new RangeError('inverted range');
  const start = cmp(a.start, b.start) >= 0 ? a.start : b.start; // later start
  const end   = cmp(a.end, b.end) <= 0 ? a.end : b.end;         // earlier end
  return cmp(start, end) < 0 ? { start, end } : null;            // empty => no overlap
}

The intersection of two ranges is always "the later of the two starts, up to the earlier of the two ends." If that computed start is not strictly before the computed end, the ranges do not actually meet and the intersection is empty — which is why the final cmp(start, end) < 0 check doubles as the overlap test. Validating for inverted input first turns a caller's bug (passing end before start) into an immediate RangeError instead of a silently empty result that quietly drops data downstream.

Once you can compute the shared window you can measure it. Feeding the intersection endpoints to start.until(end, { largestUnit: 'day' }).days gives the number of overlapping days — the figure a billing system needs to prorate a plan change, or a scheduler needs to report how badly two shifts collide. For an all-pairs conflict scan over a list of ranges, sort by start first and stop comparing once a range's start passes the current range's end; that turns a naive O(n²) sweep into something close to linear for the common case of mostly-disjoint ranges, which matters when the list is a busy team calendar.

A concrete walkthrough makes the pieces click. Suppose an existing reservation runs [2024-03-10, 2024-03-14) — check-in the 10th, check-out the 14th — and a guest requests [2024-03-14, 2024-03-18). The overlap test computes aStart < bEnd (2024-03-10 < 2024-03-18, true) and bStart < aEnd (2024-03-14 < 2024-03-14, false), so the two do not overlap and the booking is accepted: the new guest checks in exactly as the previous one leaves, sharing no night. Now request [2024-03-13, 2024-03-17) instead: aStart < bEnd is true and bStart < aEnd is 2024-03-13 < 2024-03-14, also true, so it overlaps. The intersection is the later start (2024-03-13) up to the earlier end (2024-03-14) — one contested night — which is precisely the information a UI needs to tell the guest which date is unavailable. Nothing in that logic depends on the host time zone, because every value is a civil PlainDate, so the same code gives the same answer on the booking server and in the customer's browser.

Compute the intersectionLater start, earlier end; empty means no overlapCompute the intersectionranges a,bmax(starts)min(ends)start<end?intersection

Verification

The test cases that matter are the boundary arrangements, because a broken overlap check usually passes the obvious "clearly overlapping" and "clearly disjoint" cases and fails only at the edges. Assert a partial overlap (true), two ranges that touch at a point (false — this is the case a <= bug flips), one range fully containing another (true — the case the "start inside" shortcut misses), and two clearly disjoint ranges (false). Add a self-overlap check — a range always overlaps itself — and a reversed-argument check to confirm overlaps(a, b) equals overlaps(b, a), since the condition is symmetric and any asymmetry signals a typo in one of the comparisons. It is also worth asserting that an empty range (start equal to end) overlaps nothing, including itself, because a zero-width interval contains no days to share; that case catches code that accidentally uses <= on one side, and it is exactly the kind of degenerate input that reaches production first.

import { Temporal } from '@js-temporal/polyfill';
const d = (s: string) => Temporal.PlainDate.from(s);
const r = (s: string, e: string) => ({ start: d(s), end: d(e) });

console.assert(overlaps(r('2024-03-01','2024-03-05').start, r('2024-03-01','2024-03-05').end,
                        r('2024-03-03','2024-03-07').start, r('2024-03-03','2024-03-07').end) === true);
console.assert(intersection(r('2024-03-01','2024-03-05'), r('2024-03-05','2024-03-09')) === null); // touching
console.log('overlap edge cases pass');

Overlap assertionsEvery arrangement asserts correctlyAssertions that prove the edge case[1,5) vs [3,7)overlap[1,5) vs [5,9)touching → false[1,9) vs [3,5)contained → true[1,3) vs [5,7)disjoint → false

Common pitfalls

Nearly every overlap bug is one of two mistakes. The first is using <= where the half-open convention wants <, which makes back-to-back ranges register as a conflict and rejects the legitimate case of one booking ending exactly where the next begins. The second is reaching for the "is a start inside the other?" shortcut, which needs extra clauses to cover full containment and grows a boundary bug in each one. A third, quieter trap is comparing ranges of different types — a Date-based range against a PlainDate probe — where an implicit time-of-day tips a comparison; keep both ranges in the same zoneless type. A fourth is forgetting to validate inverted input: if a caller passes a range whose end precedes its start, the two-comparison rule can return a confidently wrong answer, so reject inverted ranges at the boundary before testing them. The fix for all of these is the same discipline: half-open endpoints, one comparison type, validated ordering, and the two-part aStart < bEnd && bStart < aEnd rule — memorised as the complement of "disjoint" so you never second-guess the operators.

Overlap pitfallsWrong operator or one-sided testWrongRightuse <= on the boundarytouching counts as overlapstrict < with half-opentouching = no overlaptest only 'aStart in b'misses containmentaS<bE && bS<aEtotal condition

Frequently Asked Questions

What is the simplest way to check if two date ranges overlap?

Two half-open ranges overlap when the first starts before the second ends and the second starts before the first ends: aStart < bEnd && bStart < aEnd. Use strict less-than so ranges that merely touch at a boundary are treated as non-overlapping.

Do ranges that touch at an endpoint overlap?

Under the half-open [start, end) convention they do not. Because the end is exclusive, a range ending on the same day another begins shares no day, so the strict comparison returns false. That is what you want for back-to-back bookings.

How do I also get the overlapping days, not just a yes/no?

Compute the intersection instead of a boolean: take the later of the two starts and the earlier of the two ends, and if that start is strictly before that end you have the shared range. Passing those endpoints to start.until(end, { largestUnit: 'day' }).days gives the number of overlapping days, which is what you need to prorate a charge or report a scheduling conflict's size. A null intersection is the same as "no overlap."

Why use strict less-than instead of less-than-or-equal?

Because the ranges are half-open: the end is excluded, so a range that ends on the day another begins shares no actual day. Strict less-than encodes that, letting adjacent ranges sit flush without being flagged as a conflict. If your domain instead needs a gap between ranges — no back-to-back bookings — switch to less-than-or-equal deliberately and name the option.