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.
Oct 19 – 25, 2026
// 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.
| Events | Week, mount | Month, mount | Week, next |
|---|---|---|---|
200 | 24 ms | 44 ms | 15 ms, one frame |
1,000 | 88 ms | 101 ms | 13 ms, one frame |
5,000 | 380 ms | 368 ms | 15 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
| Do | Because |
|---|---|
Bind a $state array and push into it, or a $state.raw array and reassign it | Both are synced; pushing into a raw array changes nothing the scheduler can see |
Keep views, layouts, interactions and calendars in constants outside the markup | They 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 place | data is compared by reference when the array and the store are reconciled |
Prefer filter over rebuilding the array | filter runs on the events of the visible range only; a new array resets the store |
Past a few thousand events, use source | The 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
| Prop | Type | Default |
|---|---|---|
timeZone | TimeZoneId | local zone |
date | ZonedDateTime | today |
height | number | string | - |
source | EventSourceFn<T> | - |
filter | (event: SchedulerEvent<T>) => boolean | - |
compactBreakpoint | number | 640 |