The Rolling-Window Date Problem Hiding Inside Europe’s 90/180 Visa Rule

featured image.jpg scaled

If you ask a developer to calculate “90 days within any 180-day period,” most people reach for something that looks reasonable, ships, and is quietly wrong. The Schengen Area’s short-stay rule is one of those problems that reads like a one-liner and turns into a genuine algorithm the moment you open your editor. It’s a great small exercise in rolling-window logic and inclusive date arithmetic — two things that trip up far more codebases than they should.

Let’s build the correct version in plain JavaScript, and look at exactly where the naive approaches break.

The rule, stated precisely

A non-EU visitor can spend no more than 90 days inside the Schengen Area during any 180 days measured backward from a given date.

The load-bearing word is rolling. That 180-day span isn’t tied to the calendar year or to any fixed half-year block — it slides forward one day at a time. For every single day you are in the area, you look back across that day plus the previous 179, and your total days of presence in that span must not exceed 90.

Two more details that cause most of the bugs:

  • Both your entry day and your exit day count as days of presence. A trip from the 1st to the 10th is 10 days, not 9.
  • The window is 180 days total, which means the reference day plus the 179 days before it — an off-by-one waiting to happen.

Three ways people get it wrong

1. “90 days per half-year.” Splitting the calendar into fixed Jan–Jun / Jul–Dec blocks. This is the most common misreading, and it’s not the rule at all. A fixed-block model lets someone stay 90 days in June and another 90 in July — 180 consecutive days — which the real rolling rule forbids.

2. “Count 180 days from first entry.” Anchoring the window to the first time someone entered. The window isn’t anchored to anything; it’s recomputed for each day.

3. Exclusive date math. Doing (exit – entry) / dayLength and forgetting the + 1. Every trip comes out one day short, and over several trips the error compounds into a false “you’re fine” result.

The core function

The whole problem reduces to one question asked repeatedly: on a given reference date, how many days of the trailing 180-day window were spent inside the area?

const MS_PER_DAY = 86_400_000;

// Parse ‘YYYY-MM-DD’ as a UTC midnight timestamp.

// Using UTC sidesteps daylight-saving shifts that can cause

// off-by-one errors when you divide by a fixed day length.

const toUTC = (d) => {

  const [y, m, day] = d.split(‘-‘).map(Number);

  return Date.UTC(y, m – 1, day);

};

// stays: [{ entry: ‘YYYY-MM-DD’, exit: ‘YYYY-MM-DD’ }, …]

// Entry and exit are both inclusive days of presence.

function daysUsedOn(referenceDate, stays) {

  const ref = toUTC(referenceDate);

  const windowStart = ref – 179 * MS_PER_DAY; // ref day + 179 prior = 180 days

  let used = 0;

  for (const { entry, exit } of stays) {

    // Clip each stay to the 180-day window ending on the reference day.

    const start = Math.max(toUTC(entry), windowStart);

    const end = Math.min(toUTC(exit), ref);

    if (end >= start) {

      used += (end – start) / MS_PER_DAY + 1; // +1 because both ends count

    }

  }

  return used;

}

A quick sanity check against a known value:

daysUsedOn(‘2025-03-10’, [{ entry: ‘2025-03-01’, exit: ‘2025-03-10’ }]);

// => 10   (not 9 — both endpoints count)

Notice what the clipping does: Math.max drops any part of a stay that has aged out past the 180-day horizon, and Math.min ignores any part that hasn’t happened yet relative to the reference day. That clipping is the rolling window.

The trap: you must check every day of a planned trip

Here’s the part people miss even after they get daysUsedOn right. To validate a future trip, it is not enough to check the final day. Because the window slides, an earlier stay can roll off the back of the window partway through your new trip — so the day with the most accumulated usage isn’t always the last day. You have to test each day of the planned stay:

function tripIsLegal(plannedEntry, plannedExit, history, limit = 90) {

  const stays = […history, { entry: plannedEntry, exit: plannedExit }];

  for (let day = toUTC(plannedEntry); day <= toUTC(plannedExit); day += MS_PER_DAY) {

    const iso = new Date(day).toISOString().slice(0, 10);

    if (daysUsedOn(iso, stays) > limit) {

      return { legal: false, firstViolation: iso };

    }

  }

  return { legal: true };

}

Iterating day by day is cheap — a trip is at most a few hundred iterations — and it’s far easier to reason about (and to prove correct) than trying to find the single worst-case day analytically.

Edge cases worth a test

Before you trust any implementation of this, write cases for:

  • Inclusive endpoints — a one-day trip (entry === exit) must return 1, not 0.
  • The 180th day — a stay exactly 180 days before the reference day should be excluded; 179 days before should be included.
  • Time zones / DST — parse everything as UTC. Local-time parsing plus a fixed 86,400,000 ms day length will silently miscount across a daylight-saving boundary.
  • Overlapping or adjacent stays — if your data can contain overlaps, merge intervals first, or you’ll double-count.

Why I turned this into a tool

I kept seeing travelers do this math by hand on the back of a boarding pass and get it wrong in the stressful direction — assuming they had days left when they didn’t. So I implemented exactly the logic above, with the per-day window check, as a free Schengen 90/180 calculator. It runs entirely client-side, takes a list of past and planned stays, and tells you your remaining allowance and the first day any trip would push you over 90. If you want to see the algorithm in this article working on real dates, that’s the live version.

The broader lesson is the transferable one: any time a spec says “X within any rolling Y-day period” — rate limits, free-trial quotas, fair-use caps, compliance windows — it’s the same pattern. Recompute the window per reference point, be ruthless about whether your endpoints are inclusive, and test the boundary day explicitly. The rule that sounds like a subtraction is almost never just a subtraction.

0 0 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
0
Would love your thoughts, please comment.x
()
x