DEV Community

RollDate
RollDate

Posted on Originally published at rolldate.dev

How to Build a High-Performance JavaScript Event Calendar for Thousands of Events

Building an event calendar looks straightforward until the dataset stops being small.

Rendering a month grid is easy. Rendering a week view is manageable. Displaying a few dozen events is trivial.

The interesting problems start when the calendar has to deal with thousands of events, overlapping time ranges, all-day events, multi-day events, responsive layouts, and continuous navigation without allowing the DOM to grow forever.

While building RollDate Events, I ended up spending much more time on rendering architecture and data access than on drawing calendar cells.

This article covers the main lessons from that work.

Note: RollDate Events is currently available as a free public beta. The performance observations here come from development and stress testing, not universal benchmark claims.

The first mistake: treating the dataset and the DOM as the same thing

Suppose an application contains 10,000 events.

A naive calendar architecture can easily drift toward this idea:

events.forEach(event => {
  renderEvent(event)
})
Enter fullscreen mode Exit fullscreen mode

That is usually the wrong mental model.

The application may contain 10,000 events, but the user only needs to see a small date range at any given moment.

Those are two different problems:

  1. Storing and querying a large event dataset
  2. Rendering the currently visible portion of that dataset

The DOM should represent the visible calendar, not the size of the entire database.

That distinction became one of the most important architectural rules while building RollDate Events.

Keep the rendered date range bounded

Continuous navigation creates a subtle problem.

If moving to the next month simply appends another month to the DOM, navigation feels smooth initially:

July
August
September
October
November
...
Enter fullscreen mode Exit fullscreen mode

But the DOM keeps growing.

After enough navigation, the browser is carrying around calendar views the user can no longer see.

A better approach is to keep only a bounded number of segments mounted.

Conceptually:

[previous] [current] [next]
Enter fullscreen mode Exit fullscreen mode

When the user moves forward:

[current] [next] [new next]
Enter fullscreen mode Exit fullscreen mode

The oldest segment can be recycled or removed.

This gives the user the impression of continuous navigation without creating an ever-growing document.

The same principle works for Month, Week, and Day views even though their individual layout rules are different.

Virtualization is more than hiding elements

It is tempting to call anything involving off-screen content "virtualization".

But this:

display: none;
Enter fullscreen mode Exit fullscreen mode

is not an architecture.

If hundreds of calendar segments still exist in memory and the implementation merely hides most of them, the fundamental problem remains.

Useful calendar virtualization should bound things such as:

  • mounted date segments
  • rendered event elements
  • observers
  • listeners
  • layout work
  • cached view state

The important metric is not how much data exists.

It is how much work grows as that data gets larger.

Ideally, increasing the event dataset from 1,000 to 10,000 events should not cause a proportional increase in DOM size for the same visible date range.

Separate event storage from rendering

Once the renderer is bounded, another bottleneck becomes obvious: event lookup.

A simple implementation might repeatedly do this:

const visibleEvents = events.filter(event => {
  return event.start < rangeEnd && event.end > rangeStart
})
Enter fullscreen mode Exit fullscreen mode

For a tiny dataset, that is perfectly reasonable.

For a large dataset, repeating full-array scans during navigation, view changes, and rendering becomes unnecessary work.

The event store should therefore be able to answer questions such as:

Which events intersect this date?
Which events intersect this visible range?
Which event has this ID?
Enter fullscreen mode Exit fullscreen mode

without forcing every view to understand the entire dataset.

A useful internal model can maintain indexes by date and ID while preserving the original event objects.

Conceptually:

EventStore
├── raw events
├── events by ID
├── events by day
└── range/query cache
Enter fullscreen mode Exit fullscreen mode

The exact implementation depends on the calendar, but the important part is the separation.

Views ask for relevant events.

They should not become miniature databases.

Multi-day events make indexing harder

An event like this:

{
  start: '2026-08-10T10:00:00',
  end: '2026-08-10T11:00:00'
}
Enter fullscreen mode Exit fullscreen mode

belongs to one day.

An event like this:

{
  start: '2026-08-10T10:00:00',
  end: '2026-08-13T15:00:00'
}
Enter fullscreen mode Exit fullscreen mode

intersects several days.

If the event store indexes only the start date, later days may fail to find the event.

For date-based indexes, multi-day events need to be discoverable from every relevant date or through a range index capable of returning intersections correctly.

This also matters for all-day events.

Calendar storage logic should answer:

Does this event intersect the requested range?

not merely:

Did this event start today?

Those questions produce very different calendars.

Month, Week, Day, and Agenda are different rendering problems

One of the easiest architectural mistakes is forcing every calendar view through the same renderer.

They share event data, navigation state, locale, themes, and configuration.

But their layouts are fundamentally different.

Month

Month view is primarily a date grid.

Its main constraints are:

  • cell density
  • event limits
  • multi-day representation
  • compact layouts
  • overflow indicators

Week

Week view introduces a time axis and overlapping timed events.

It needs:

  • day columns
  • time positioning
  • overlap calculation
  • all-day regions
  • visible-hour constraints

Day

Day view uses similar time calculations but has much more horizontal room for a single date.

Agenda

Agenda is closer to a chronological list.

Trying to force it through the same DOM structure as Week view would create complexity for no useful reason.

A cleaner architecture is a common view contract:

interface View {
  mount(): void
  update(): void
  destroy(): void
}
Enter fullscreen mode Exit fullscreen mode

with separate implementations:

MonthView
WeekView
DayView
AgendaView
Enter fullscreen mode Exit fullscreen mode

Shared infrastructure stays shared.

View-specific layout stays inside the view.

Overlapping events need grouping before columns

Timed events introduce another deceptively difficult problem.

Consider:

Event A: 09:00 – 11:00
Event B: 09:30 – 10:30
Event C: 12:00 – 13:00
Enter fullscreen mode Exit fullscreen mode

A and B overlap.

C does not.

If the calendar calculates one global maximum overlap count for the entire day, C may unnecessarily inherit the narrow width required by A and B.

Instead, events should first be divided into collision groups.

Conceptually:

Group 1
A 09:00 ───────── 11:00
  B 09:30 ───── 10:30

Group 2
C 12:00 ───── 13:00
Enter fullscreen mode Exit fullscreen mode

Column allocation can then happen independently inside each group.

This produces better use of horizontal space and avoids unrelated events affecting each other's width.

All-day events should not disappear outside Month view

Another common shortcut is to treat all-day events as a Month-only feature.

That creates inconsistent behavior.

If an event exists on a date, switching from Month to Week should not make it mysteriously disappear.

Week and Day views therefore need an explicit all-day region.

The rendering pipeline becomes roughly:

Week
├── day headers
├── all-day row
└── timed grid
Enter fullscreen mode Exit fullscreen mode

All-day and timed events are different visual categories, but they belong to the same underlying event model.

Responsive calendars should respond to their container

Viewport media queries are often not enough for reusable UI libraries.

A calendar might live inside:

  • a dashboard panel
  • a modal
  • a sidebar
  • a split layout
  • an embedded application

A 1440px browser window does not mean the calendar itself has 1440px available.

For reusable components, the important width is often the container width.

Using ResizeObserver allows the calendar to react to the space actually available to it:

const observer = new ResizeObserver(entries => {
  const width = entries[0].contentRect.width

  updateCalendarDensity(width)
})
Enter fullscreen mode Exit fullscreen mode

That can drive compact behavior such as:

  • replacing Month event titles with dots
  • reducing visible Week columns
  • hiding secondary Agenda metadata
  • changing toolbar layout

The goal is not simply to shrink everything.

The goal is to preserve useful information at each density.

Mobile density is an information-priority problem

A desktop Month cell may have enough room for:

Team meeting
Product review
Release planning
+3 more
Enter fullscreen mode Exit fullscreen mode

On a narrow mobile container, attempting to preserve the same presentation usually creates unreadable noise.

A better compact Month representation may be:

• • •
+3
Enter fullscreen mode Exit fullscreen mode

The information priority changes:

  1. This date contains events
  2. There are multiple events
  3. Exact titles can be discovered after interaction

The same principle applies to Agenda view.

On desktop:

DATE | TIME | TITLE | LOCATION
Enter fullscreen mode Exit fullscreen mode

On mobile, location may become secondary while time and title remain readable.

Responsive design is not just CSS compression.

It is deciding which information matters most.

Continuous navigation and direct navigation solve different problems

Previous / Next navigation is excellent for nearby dates.

Continuous scrolling is excellent for exploration.

Neither is ideal for jumping from August 2026 to March 2028.

A complete calendar navigation model therefore benefits from separating:

  • relative navigation
  • continuous navigation
  • direct date navigation

These mechanisms are complementary.

Trying to make one interaction solve every navigation problem usually makes it worse at its original job.

Large datasets do not justify huge DOM trees

During development, I stress-tested the calendar with generated datasets at several sizes, including:

  • 1,000 events
  • 5,000 events
  • 10,000 events

The goal was not to produce a marketing number like "supports 10,000 events".

That statement by itself means almost nothing.

Ten thousand events spread across ten years is very different from ten thousand events in one week.

The useful questions were:

  • Does DOM size remain bounded?
  • Does navigation remain responsive?
  • Does event lookup scale reasonably?
  • Are hidden dates still being rendered?
  • Does switching views trigger unnecessary work?
  • Does memory grow after repeated navigation?

Those questions reveal architectural problems much faster than a single FPS counter.

Performance is more than FPS

A calendar can display 60 FPS and still be poorly designed.

For example, it might:

  • allocate excessive memory
  • perform expensive work before rendering
  • retain old DOM nodes
  • leak observers
  • become slow only after repeated navigation
  • freeze when replacing a large dataset

So performance testing should look at several dimensions.

Dataset preparation

How long does indexing or replacing the event dataset take?

DOM size

Does the number of nodes stay reasonably stable while navigating?

Interaction latency

Does the calendar respond quickly to navigation and view changes?

Memory behavior

Are old views, listeners, and observers actually destroyed?

Layout stability

Does responsive behavior cause unnecessary reflow or visual jumps?

FPS is useful.

It is just not a complete performance model.

Data updates should not require rebuilding the calendar

Real applications rarely load events once and never touch them again.

A useful event calendar API needs operations such as:

calendar.setEvents(events)
calendar.addEvent(event)
calendar.updateEvent(event)
calendar.removeEvent(id)
Enter fullscreen mode Exit fullscreen mode

The event store can update its indexes and invalidate only the affected cached ranges.

This becomes especially important when event data comes from APIs, WebSockets, collaborative applications, or background synchronization.

A calendar should be a long-lived UI component, not something that needs to be destroyed and recreated every time an event changes.

Cleanup is part of performance

destroy() is not glamorous, but reusable UI libraries need it.

A calendar may own:

  • DOM nodes
  • ResizeObserver
  • animation frames
  • event listeners
  • cached view instances
  • internal stores

Destroying the visible root element while leaving those resources alive is a leak disguised as cleanup.

A proper lifecycle should make repeated mounting and unmounting safe.

This matters particularly in SPAs where components can be created and removed many times during one browser session.

What I learned from building RollDate Events

These ideas are now being applied in RollDate Events, a JavaScript event calendar currently available as a free public beta.

The current beta includes:

  • Month, Week, Day, and Agenda views
  • continuous navigation
  • timed and all-day events
  • multi-day events
  • overlapping event layouts
  • responsive layouts
  • event CRUD APIs
  • localization
  • TypeScript
  • zero runtime dependencies

The current package version is 0.1.0-beta.0.

It is deliberately still labeled beta. The public API and implementation may continue to evolve before a stable 1.0 release.

Features such as drag and drop, resizing, recurring-event expansion, resource scheduling, and timeline views should not be assumed to exist in the current free beta. They are separate problems that can build on the architecture described above.

Try it

Install the public beta:

npm install @rolldate/events@beta
Enter fullscreen mode Exit fullscreen mode

Quick start:

import { RollDateEvents } from '@rolldate/events'
import '@rolldate/events/styles'

const calendar = new RollDateEvents('#calendar', {
  defaultView: 'week',
  events: [
    {
      id: 'meeting',
      title: 'Team meeting',
      start: '2026-09-02T10:00:00',
      end: '2026-09-02T11:00:00'
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

The main takeaway

The hardest part of a large event calendar is not drawing rectangles in a grid.

It is controlling how much work the browser performs as the dataset, visible range, and number of interactions grow.

The architecture that worked best for me was based on a few rules:

  • keep the rendered date range bounded
  • separate event storage from view rendering
  • query only the events relevant to the visible range
  • treat each view as its own layout problem
  • respond to container width rather than assuming viewport width
  • test DOM growth and lifecycle behavior, not only FPS

Once those foundations are correct, features can be added without making every new capability another performance problem.


Originally published on the RollDate blog.

Top comments (0)