Advanced

SSR & Performance

The scheduler renders on the server without touching the window, and in the browser it only ever lays out the range on screen. This page says what that means in numbers, and where the limits are.

Rendering on the Server

Nothing in the render path reads window, so a page with a scheduler works with SvelteKit's default SSR and the first paint has the events. Two things to set for the server and the browser to agree: timeZone (the server's zone is not the reader's; see Time Zones) and, when the page must be reproducible, date (the default is now, which differs between the request and the hydration by a moment, and by a day around midnight).

<!-- +page.server.ts -->
export const load = async ({ locals, fetch }) => ({
  timeZone: locals.user.timeZone,                    // so the server and the browser agree on the day boundaries
  events: await fetch('/api/events?from=...').then((r) => r.json())
});

<!-- +page.svelte: nothing here touches window. The first paint has the events. -->
<script lang="ts">
  let { data } = $props();
  let events = $state(data.events);
</script>

<Scheduler bind:events timeZone={data.timeZone} date={parseZonedDateTime(data.anchor)} height={640} />

Hydration and the Compact Switch

The server has no width to measure, so it renders the desktop layout. On a phone the scheduler switches to compact once it has measured itself, right after hydration. A fixed height keeps the page from jumping while that happens. Every page of these docs with a scheduler is rendered on the server and hydrates without a warning.

<!-- The server has no width to measure, so it renders the desktop layout: the
     full toolbar, seven month columns, a docked sidebar. A phone switches to the
     compact layout after hydration. Give the scheduler a fixed height so the page
     does not jump while that happens. -->
<Scheduler bind:events height={560} />

<!-- There is no server-side compact: compact is decided from the measured width,
     and the server has none. The switch happens once, right after hydration. -->

Only the Visible Range

The store keeps events in an index sorted by start. A view asks for its range; the store finds the first candidate by binary search, walks forward until the starts pass the range, and expands series only inside it. The layout and the filters then run on those events, not on the whole array. Navigating a week is a query, not a re-read.

Nothing Moves Until You Drop

During a drag the store is untouched. The view draws a ghost from the preview and the other events stay where they are. The store receives one patch on drop, the array one change, and onMutate one call.

Loading on Demand

An array is right up to a few thousand events. Past that, the cost is not the layout but the array itself: parsing dates, normalising, and sending it over the wire. A source loads only the ranges the reader looks at; see Loading on Demand.

<!-- Past a few thousand events, load the visible range instead of the year -->
<Scheduler source={({ range, timeZone, signal }) => api.events(range, timeZone, { signal })} />

Measured

The demo generates N timed events over eight weeks around today and measures the time from assigning the array to the DOM being updated. The table has the numbers from the machine these docs were built on; the buttons measure yours.

Press a count to measure

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
// How the numbers on this page were taken: a scheduler on this page, N events over
// eight weeks, the time from assigning the array to the DOM being updated.
const started = performance.now();
events = generate(count);
await tick();
elapsed = performance.now() - started;

On the build machine

Headless Chrome 153 on an 8-core Mac laptop, median of five. Mount is the time from assigning the array to the DOM being updated; next is one press of the Next arrow, click to the second frame. Navigation stays at one frame because it is a range query, not a re-read of the array.

EventsWeek, mountMonth, mountWeek, next
20024 ms44 ms15 ms, one frame
1,00088 ms101 ms13 ms, one frame
5,000380 ms368 ms15 ms, one frame

Not Virtualized

The month and agenda views draw every event in their range; the week draws every event of its seven days. None of the views virtualizes, so a single day with hundreds of overlapping events is drawn in full and will be slow to lay out and to scroll. If your data is that dense, narrow it with filter or a source that returns a summary for wide ranges.

Tips

Keep it fast

DoBecause
Bind a $state array and push into it, or a $state.raw array and reassign itBoth are synced; pushing into a raw array changes nothing the scheduler can see
Keep views, layouts, interactions and calendars in constants outside the markupThey are compared by reference. A literal in the template is a new array every render, and the registry is rebuilt
Reassign event.data, never mutate it in placedata is compared by reference when the array and the store are reconciled
Prefer filter over rebuilding the arrayfilter runs on the events of the visible range only; a new array resets the store
Past a few thousand events, use sourceThe browser then only ever holds the ranges the reader has looked at
<script lang="ts">
  // Arrays and objects passed as props are compared by reference. Build them once,
  // outside the template, or the scheduler rebuilds its registry on every render.
  const views = [workWeek];
  const interactions = [pointerTime];
  const calendars = [/* ... */];

  // events: a $state array you push into, or a $state.raw array you reassign.
  // Both work; mixing them (push into raw) does not.
  let events = $state<EventInput[]>([]);
</script>

<Scheduler bind:events {views} {interactions} {calendars} />

<!-- Not this: a new array on every render -->
<Scheduler bind:events views={[workWeek]} interactions={[pointerTime]} />
// filter runs on the events of the visible range only, after the range query,
// so it is cheap even on a large store. Keep it a pure function of the event.
filter={(event) => event.data?.status !== 'cancelled'}

// data is kept by reference. Reassign an event's data to change it; do not
// mutate the object in place, or the change is invisible to the sync.

Props

PropTypeDefault
timeZoneTimeZoneIdlocal zone
dateZonedDateTimetoday
heightnumber | string-
sourceEventSourceFn<T>-
filter(event: SchedulerEvent<T>) => boolean-
compactBreakpointnumber640