PostsEngineering

Recurring events are harder than they look

"Every Tuesday at 8" sounds like one line of code. It turned out to be a few months of clocks, calendars and people who had already bought tickets.

A calendar grid where one weekly session slips an hour as the clocks change.

Recurring events were one of the things people asked us for most. A weekly comedy night, a yoga class three mornings a week, a museum with timed entry every half hour. Before v2 you had to create each date as its own event, which is fine for four dates and miserable for two hundred.

“Every Tuesday at 8” sounds like one line of code. It took a good few months, and most of that time went on three things: clocks, calendars, and the fact that people buy tickets for dates you might later want to change. This is a write-up of what we ran into and how the code handles it. Everything here is in the open-source repo if you want to read along.

What a schedule looks like

The first decision was how to store the rule itself. The obvious answer is RRULE, the format from the iCalendar spec that every calendar app understands. We went with our own JSON instead, and it’s a fairly small object:

{
  "frequency": "weekly",
  "interval": 1,
  "days_of_week": ["tuesday"],
  "times_of_day": [
    { "time": "20:00", "label": "Evening show", "duration_minutes": 90 }
  ],
  "range": { "type": "until", "start": "2026-10-06", "until": "2027-06-29" },
  "excluded_occurrences": ["2026-12-29 20:00"]
}

RRULE can describe the repeat, and EXDATE and RDATE cover skipped and extra dates. You could do the time slots too, with one VEVENT per slot, each with its own RRULE, length and title. We went our own way for two reasons. We wanted a single object per schedule that the form in the app edits directly. And in two places we wanted different behaviour from the spec. RFC 5545 says a recurring session that falls in the hour the clocks skip should be dropped, and so should a yearly 29 February in the years that don’t have one. We move the first and clamp the second, so we’d have been working against any RRULE library anyway.

The rule never leaves the organiser’s time zone. “20:00” means eight in the evening wherever the event is, and that’s where the first real problem starts.

When the clocks change

The tempting way to build this is to work out the first date in UTC, then add seven days for every week after it. It works perfectly until the clocks change, and then every date until the next change is an hour out.

Here’s what that looks like. The middle column is the add-seven-days approach, and the last column is what the organiser actually asked for.

Interactive

The same weekly session, stored two ways

DateFirst date + 7 days, in UTC20:00 in Dublin, every time
Tue 6 Oct20:0020:0019:00 UTC
Tue 13 Oct20:0020:0019:00 UTC
Tue 20 Oct20:0020:0019:00 UTC
Tue 27 Oct19:00 1 hour early20:0020:00 UTC
Tue 3 Nov19:00 1 hour early20:0020:00 UTC
Tue 10 Nov19:00 1 hour early20:0020:00 UTC

Dates run across the next clock change in Dublin after September 2026. Worked out in your browser with its own copy of the IANA time zone data, the same source the server uses.

The fix is to never do arithmetic on the UTC times. The parser walks the calendar in the organiser’s own time zone, sets the wall-clock time on each date, and only then converts that one date to UTC:

$start = $date->setTime((int) $parts[0], (int) $parts[1], 0);
// ...
$startUtc = $start->setTimezone('UTC');

That’s from RecurrenceRuleParserService. Each date gets its own conversion, so 20:00 in Dublin is 19:00 UTC in early October and 20:00 UTC once the clocks go back on the 25th, and both are right.

A recurring rule has to be kept in the organiser’s local time and time zone and turned into UTC one date at a time, because adding 7 days to a UTC time moves every later session by an hour in places that change their clocks.

There’s a catch here that we haven’t solved yet. Once a date is generated, we store it in UTC. If a government changes its clock rules, which happens more often than you’d think (Mexico dropped daylight saving in 2022, for one), or an organiser changes the event’s time zone, the stored dates don’t move with it. Regenerating fixes the dates nobody has bought, but sold ones stay at the old instant. The proper fix is to match dates on their local time and work the UTC out again when the rules change.

The hour that doesn’t exist

Weekly shows at eight in the evening are the easy case. The awkward ones are sessions in the small hours, which sounds rare until you sell tickets for club nights.

When the clocks go forward, an hour disappears. In Dublin, 01:00 to 01:59 on the last Sunday in March never happens. When they go back, that hour happens twice. If someone sets up a weekly Sunday session at 01:30, one date a year asks for a time that doesn’t exist, and another asks for a time that exists twice. The code still has to pick something.

From our backend

The hour that doesn't exist, and the one that happens twice

Clocks go forward · Sunday 28 March 2027

01:30 02:30

The clocks skip straight over 01:00 to 01:59 in Dublin that night. A session set for 01:30 gets pushed forward to 02:30 local (+01:00), which is 01:30 UTC.

Clocks go back · Sunday 25 October 2026

01:30 ×2

01:00 to 01:59 happens twice. Asked for 01:30, the backend picks the one at +00:00, which is 01:30 UTC. The other 01:30 is 00:30 UTC.

Compare Dublin and London. Same wall clocks, same night, same code, but the repeated 01:30 lands an hour apart. Dublin gets the second one.

These are the real results from the Hi.Events backend (PHP 8.5, Carbon, tz database 2026.1) when a schedule asks for that time on that date.

For the missing hour, the time moves forward an hour, so 01:30 becomes 02:30 in Dublin and 02:30 becomes 03:30 in New York. RFC 5545 treats a single date in that hour the same way, although for a recurring rule it says to drop the session altogether. We’d rather the session still happened that night, a little later, which is what the clock on the wall would suggest anyway. There’s a test for this in New York, so a library upgrade can’t quietly change it.

The repeated hour is where we found something we didn’t expect while writing this post. Dublin and London share the same clocks all year. Ask our parser for 01:30 on the night the clocks go back and London gets the first 01:30, while Dublin gets the second one, an hour later in real time.

It also depends on how you build the time. The parser starts each date at midnight and then sets the time on it. Create the time in one go, with new DateTime('2026-10-25 01:30'), and you get the second 01:30 in both cities. As far as we can tell, PHP uses the daylight saving flag of the time you’re changing as a hint. Under the Standard Time Acts, Irish summer time is legally the standard time and winter time is the exception, and the time zone database records it that way, with winter marked as the daylight saving part of the year for Dublin. So Dublin’s midnight carries the opposite flag to London’s, and the answer flips.

RFC 5545 says an ambiguous time means the first one, so by the spec’s standard Dublin is getting it wrong. It only touches a session at half one in the morning, one night a year, in Ireland, but it’s a bug all the same. The fix is to pick the first 01:30 explicitly and stop leaving it to PHP, with tests for European zones alongside the New York ones, which is how this slipped through in the first place.

The 31st of February

Calendars cause a quieter version of the same problem. “Every month on the 31st” is a perfectly reasonable thing to type, and seven months a year don’t have a 31st.

There were two options: skip those months, or move the date back to the last day of the month. For monthly rules we skip them, which is also what RFC 5545 does. Someone who picks the 31st usually means that date, and a session quietly appearing on the 30th of April would be a surprise. If what you really want is one session near the end of every month, a “last Friday of the month” rule always lands.

Yearly rules go the other way, and this is one of the places we part company with the spec. An annual event on the 29th of February moves to the 28th in the three years out of four that don’t have one, because skipping would mean your yearly event happens every four years.

Interactive

"Every month on the 31st"

Jan31
Febskip
Mar31
Aprskip
May31
Junskip
Jul31
Aug31
Sepskip
Oct31
Novskip
Dec31

7 of 12 months get a date in 2027.

Every year on 29 February
202728 Feb
202829 Feb
202928 Feb
203028 Feb
203128 Feb
203229 Feb
203328 Feb
203428 Feb

Monthly rules skip months that are too short. Yearly rules on a date that doesn't exist that year move back to the last day of the month.

Changing a schedule that’s already on sale

This is the part that took longest, and there’s nothing clever about it. It’s just fiddly.

An organiser sets up a year of Tuesdays, sells tickets for a few of them, then changes the schedule. Maybe they add Thursdays, move the show to Wednesdays, or push the end date out. The app regenerates the dates from the new rule, and the question is what happens to the dates that already exist.

The generator matches each new date against the existing ones by its start time in UTC. A date that still matches is kept, and updated if its end time, capacity or label changed, unless it’s in use. New dates are inserted. Anything left over is a date the new rule no longer produces, and for those the answer depends on whether anyone has touched them.

If a date has orders, attendees, price overrides or product visibility settings, it’s in use, and it’s never deleted. It gets marked as overridden and left alone, so the people with tickets still have a date to turn up to. If an organiser has changed a date’s time, capacity or venue by hand, it’s marked as overridden too, and regeneration skips it. Cancelled dates are kept for the record. Only dates nobody has touched are deleted, and any waitlist entries on those dates are cancelled with them.

One consequence surprises people. If you change “Tuesdays at 20:00” to “Tuesdays at 20:30” after selling tickets, the Tuesdays you’ve already sold end up with both. The 20:00 dates with tickets (or their own prices) stay where the ticket holders expect them, and a new 20:30 date appears next to each one. Unsold 20:00 dates are simply replaced. Moving existing dates is a separate action, the bulk edit, which shifts the times of the dates you pick and records the old times as exclusions, so a later regeneration won’t put them back. If you want to move a schedule that’s on sale, shift the dates first and then change the rule, and you won’t get duplicates.

When a recurring schedule changes after tickets are sold, dates with orders or attendees on them are never deleted, so nobody holding a ticket loses the date they bought.

The date that came back from the dead

Generating a year of dates can take a while, so it runs as a queued job and the app polls until it’s done. That opens up a race that’s easy to miss.

The job carries a copy of the rule from when it was queued. If an organiser deletes a date, or moves one to a different time, while the job is still waiting its turn, the live rule gets an exclusion for the old slot, but the job’s copy doesn’t have it. The job runs, finds nothing at that time, and creates the date again. Cancelled dates were never at risk, because a cancelled date keeps its row and the generator matches it.

The fix is to take a lock on the event when the job starts, re-read the live rule, and merge in anything excluded since the job was queued:

$this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$dto->event_id]);
 
$event = $this->eventRepository->findByIdLocked($dto->event_id);
 
$rule = $this->mergeLiveExclusions($dto->recurrence_rule, $event->getRecurrenceRule() ?? []);

That’s in GenerateOccurrencesFromRuleHandler. Exclusions are stored by their local date and time, like “2026-12-29 20:00”, which is also what stops a later regeneration from bringing an excluded slot back.

That closes the gap we were worried about. It doesn’t make the job completely safe, though. Two schedule edits queued back to back still run in whatever order the workers pick them up, and merging only ever adds exclusions, so a date reactivated while a job is waiting can end up excluded again. Both are on our list to tighten up.

Every date has its own capacity

Once dates exist, each one needs its own capacity. Each has a capacity and a count of places used, and “sold out” is worked out from those two numbers every time, so there’s no separate sold-out flag to drift out of sync with them.

Tickets on a recurring event count per date by default. A ticket type with 50 available means 50 for every date, and an organiser can override that for a single date. Checkout locks the event, re-checks availability for each date in the order without using the cache, and then checks the whole order again, because one order can include tickets for more than one date.

1,200 and no more

A rule like “every 15 minutes, every day, forever” would generate dates until the database fell over, so there’s a cap of 1,200 dates per event. That’s enough for three sessions a day for a full year, with some to spare. It’s tight for something like half-hourly timed entry, which uses it up in under three months, so those schedules need setting up a season at a time for now.

The parser generates one date past the cap on purpose, so the app can tell the difference between exactly 1,200 dates and too many. The form shows a running count while you build the rule, and before anything is created the server does a dry run and turns down a rule that goes over.

There’s plenty more to do

This is the first version of recurring events, and there’s still a lot we want to add. If you run recurring events, your feedback is invaluable. Tell us what’s missing or what trips you up, either in a GitHub issue or by email to [email protected], and we’ll read it.

Share this post
All posts
ReleaseHi.Events v2: recurring events, reserved seating and a box officeThe biggest release since we started. Events that repeat, seats people can pick, a till for the door, and a much nicer way to set up tickets.GuideHow many people do you need on the door?The length of a door queue comes down to when people arrive and how fast you can scan them. Set up your own night in the simulator and watch it play out.

Start selling today

Create your first event and share the link. No credit card, no sales call.

Free events cost nothing. Paid tickets are 1.25% + $0.60, with no monthly fee and no contract — see the full pricing.