ProductionApplies toLite and Pro
Virtualization and performance
Both editions virtualize in two dimensions: only the rows and dates around the viewport have DOM nodes, and scrolling, zooming and dragging update that DOM directly without React renders. In an integration, time goes into your render hooks, React render slots, data mapping and props whose identity changes on every render. Keep hooks to cheap lookups, keep props and event objects stable, and measure production builds with CPU throttling.
SuperScheduler is built for plans with thousands of rows and hundreds of thousands of events. The engine is an imperative DOM renderer: React hosts it, and your React tree renders only when your own props or state change. This guide explains what the engine does for you, where an integration can still spend time, and how to measure honestly.
Virtualization applies to both editions. Lite virtualizes rows, days and events with the same core indexes and never renders React while scrolling. Render hooks, zoom, level of detail and React render slots are Pro features, so the sections about them apply to Pro only.
How virtualization works
The mounted window
Only a window around the viewport has DOM nodes: the visible area plus a margin, snapped to chunks of a quarter of the viewport, with two chunks of margin on each side. The window is recomputed on every scroll event but changes only when a chunk boundary is crossed. On a scroll frame the viewport plus one chunk is painted synchronously; the rest of the window is painted progressively over the next frames, nearest rows first. Calls such as control.update() render synchronously and are never spread over frames.
Rows
Resources are flattened into rows once (tree rows included), and row heights live in a prefix-sum index, so finding the rows for a scroll position is logarithmic, not a walk over every row. Collapsing, expanding or filtering rebuilds the visible row list in one pass. A row whose height changes shifts the rows below it as whole slices instead of restyling each cell and event.
Cells
Grid lines and weekend or non-business shading are painted as a repeating background, so ordinary cells have no DOM nodes at all. A cell gets a node only when it is customized (by onBeforeCellRender or renderCell) and inside the mounted window. When you zoom out below 2 px per cell, customized cells are not created and their hook is not called.
Events
Events are indexed per resource by time, so a visible range is found with a logarithmic query. Overlap stacking is computed from times, not pixels, so zooming never restacks rows. Event nodes come from a pool and are reused as the window moves. At small sizes the level of detail switches events from full content to text, plain blocks and finally one bar per row, and hides links whose ends are too small to see. lod: false turns that adaptation off and costs more when zoomed out.
No React renders during interaction
Scroll, zoom, hover, selection and drag frames are handled by the engine with cached geometry and one frame scheduler that separates layout reads from DOM writes. React content from super-scheduler/react-render is the one exception by design: the HTML or text fallback paints first, and React content is published after the interaction ends, in batches that target 8 ms. Optional features such as keyboard navigation, menus and bubbles load as separate chunks when you configure or first use them, never during a gesture.
What costs time in an integration
The library cannot make your callbacks cheaper. These are the places where real integrations spend time.
Render hooks
| Hook | When it runs | Cached until |
|---|---|---|
onBeforeEventRender | For every event the layout needs, not only visible ones, because it can change height, line or hidden | The event's data changes |
onBeforeCellRender | For every cell inside the mounted window, above 2 px per cell | New resources, control.update(), or a change to the row's events when the resource has cellsAutoUpdated: true |
onBeforeRowHeaderRender | For mounted row headers | The row or its resource data changes |
onBeforeTimeHeaderRender | For mounted header cells | The time axis changes (scale, zoom level, dates) |
onEventMoving, onEventResizing, onTimeRangeSelecting | On every shadow change of a gesture | Never cached |
Passing a new function for a render hook also clears its cache. Keep hooks to lookups and string building. Precompute maps and sets outside the hook, create Intl formatters once at module scope, never read layout (getBoundingClientRect) and never set React state inside a hook. During a drag, args.conflicts is computed on first access, so do not read it unless the rule needs it.
Props whose identity changes
The React component forwards only the props whose identity changed since the last render (Object.is). Each forwarded prop has a cost:
- A new
eventsarray whose objects differ from the control's reloads the whole event store and runsonBeforeEventRenderagain for every event. Handing back the same objects ([...args.events]fromonEventsChange) is recognized as an echo and skips the reload. - A new
resourcesarray rebuilds the rows and invalidates every cell. - A new hook function invalidates that hook's cache.
- New
timeHeaders,zoomLevels,classNamesorstylesobjects are applied again.
Define constants at module scope, memoize derived props with useMemo, and wrap handlers that depend on state in useCallback. A prop that disappears between renders goes back to its default, so keep conditional props stable too.
import { useCallback, useMemo, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type {
SchedulerBeforeCellRenderArgs,
SchedulerBeforeEventRenderArgs,
SchedulerEventsChangeArgs,
} from 'super-scheduler'
type Stay = { status: 'confirmed' | 'tentative'; guests: number }
// Module scope: created once for the life of the page.
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
{ groupBy: 'Month' },
{ groupBy: 'Day', format: 'd' },
]
const STATUS_CLASS = {
confirmed: 'stay stay--confirmed',
tentative: 'stay stay--tentative',
} as const
// Runs for every event the layout needs, then is cached per event: keep it to lookups and strings.
function onBeforeEventRender(args: SchedulerBeforeEventRenderArgs) {
const data = args.data as SuperScheduler.EventRenderData<Stay>
data.cssClass = STATUS_CLASS[data.status]
data.html = `${SuperScheduler.Util.escapeHtml(data.text)} <small>${data.guests}</small>`
}
interface Props {
readonly rooms: SuperScheduler.ResourceData[]
readonly initial: SuperScheduler.EventData<Stay>[]
/** Days the hotel is closed, as `yyyy-MM-dd`. */
readonly closedDays: readonly string[]
}
export function StablePlanning({ rooms, initial, closedDays }: Props) {
// Map server data to event objects once; new objects on every render would reload the store.
const [events, setEvents] = useState(initial)
const owned = useMemo(() => events.slice(), [events])
// A Set built when its input changes, so the cell hook is a constant-time lookup.
const closed = useMemo(() => new Set(closedDays), [closedDays])
const onBeforeCellRender = useCallback(
(args: SchedulerBeforeCellRenderArgs) => {
if (closed.has(args.cell.start.toString('yyyy-MM-dd'))) args.cell.properties.disabled = true
},
[closed],
)
// The same objects handed back are recognized as an echo: no reload, no repaint.
const onEventsChange = useCallback((args: SchedulerEventsChangeArgs) => {
setEvents([...args.events] as SuperScheduler.EventData<Stay>[])
}, [])
return (
<SuperSchedulerComponent
startDate="2026-01-01"
days={365}
scale="Day"
cellWidth={40}
timeHeaders={TIME_HEADERS}
resources={rooms}
events={owned}
onEventsChange={onEventsChange}
onBeforeEventRender={onBeforeEventRender}
onBeforeCellRender={onBeforeCellRender}
/>
)
}React render slots
renderEvent, renderRowHeader and the other render props mount React content through portals into engine nodes. They are fine for events and headers. renderCell mounts one React slot per mounted cell, which adds up quickly on a dense grid; prefer onBeforeCellRender strings for large grids and keep React for the cells that need interaction. Memoize the render functions, and tune renderOptions.retain (detached items kept, default the smaller of 2,000 or twice the mounted count) and renderOptions.sliceMs (batch target, default 8 ms) only after measuring. See React render slots.
Data changes and your own state
control.events.add,updateandremoveare incremental and suit single edits. For hundreds of changes at once, such as an import or a server refresh, hand the scheduler one new array instead of calling them in a loop.control.update()without arguments is a full refresh. Pass only the options that changed.onZoomruns on every frame of a zoom gesture. Write per-frame feedback to the DOM, and update React state only whenargs.phase === 'end'.useScheduler({ track: [...] })fromsuper-scheduler/hookspublishes after changes settle, never per frame. Track only the topics a component displays.- Collapsed tree parents reduce mount work: layout is computed for expanded rows.
Checklist
- Import the scheduler on the routes that use it, so its code stays out of the rest of your app (Server rendering).
- Keep
events,resources,timeHeaders,zoomLevels,classNamesand hooks stable between renders. - Map server rows to event objects once per response, not during render.
- Give the control its own copy of the events array (
useMemo(() => events.slice(), [events])) because it splices that array in place. - Keep
onBeforeEventRenderandonBeforeCellRenderto lookups; setcellsAutoUpdatedonly on rows whose cells depend on their events. - Prefer string hooks over
renderCellon dense grids. - Batch large data changes into one new array.
- Load long timelines by range with
super-scheduler/rangesinstead of sending years of data. - Leave
lodon unless you need literal rendering at every zoom. - Never set React state from per-frame callbacks.
Measuring
The library is measured with a reproducible method, and the same method works for your integration:
- Production builds. Development builds of React and of your app are slower and add checks.
- Separate phases. Generate or fetch data before mounting, then measure mount time with a forced layout after it.
- Frames, not averages. Record frame-time p50, p95 and p99 while scrolling with a wheel over the grid, diagonally, during autoscroll and at several zoom widths. Count mounted DOM nodes and memory after garbage collection.
- React commits. Wrap the scheduler in a
<Profiler>and confirm that scrolling, zooming and dragging cause no commits.onRenderfires in development builds; in production it needs React's profiling build. - Throttled CPU. Repeat with 4× CPU throttling in the browser's performance tools, and on the devices your users have.
- Isolate your callbacks. Compare no hook, an empty hook and your hook to see what your code costs.
- Repeat runs. A single slow sample near a threshold is noise. Compare medians of several runs on an otherwise idle machine.
super-scheduler/datasets generates the same deterministic scenarios the library uses, so you can reproduce a load without your backend:
import { Profiler, useRef, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { generateScenario, toSuperSchedulerData } from 'super-scheduler/datasets'
// Deterministic data generated before mounting, so generation is not measured as mount time.
// S2: 1,000 rows over 730 days from 2026-01-01, about 40,000 events. Same seed, same data.
const S2 = toSuperSchedulerData(generateScenario('S2'))
type DatasetResource = (typeof S2.resources)[number]
// The generator's resource type is an interface without an index signature, so it is not directly
// assignable to ResourceData: copy the fields the scheduler needs instead of casting.
const toResource = (resource: DatasetResource): SuperScheduler.ResourceData => ({
id: resource.id,
name: resource.name,
...(resource.expanded === undefined ? {} : { expanded: resource.expanded }),
...(resource.frozen === undefined ? {} : { frozen: resource.frozen }),
...(resource.children === undefined ? {} : { children: resource.children.map(toResource) }),
})
const RESOURCES = S2.resources.map(toResource)
export function ScrollProfile() {
const [events] = useState(() => S2.events.slice())
const commits = useRef(0)
const counter = useRef<HTMLOutputElement>(null)
// Written straight to the DOM: a state update here would itself cause the renders we count.
const onRender = () => {
commits.current += 1
if (counter.current !== null) counter.current.textContent = `${commits.current} React commits`
}
return (
<>
<output ref={counter}>0 React commits</output>
<Profiler id="planning" onRender={onRender}>
<SuperSchedulerComponent
startDate="2026-01-01"
days={730}
scale="Day"
cellWidth={32}
treeEnabled
heightSpec="Fixed"
height={640}
resources={RESOURCES}
events={events}
/>
</Profiler>
</>
)
}You should see the counter stop after the initial mount: scrolling through the 1,000 rows and two years of the plan adds no React commits.
Published measurements
The library's performance review of 2026-10-07 recorded these results. Method: Chromium 145 headless with software rasterization, 1440 × 900 viewport at device pixel ratio 1, the library's demo in a production build with React's profiling build, on an Apple M5 Pro with 24 GiB running other applications. Frame times are requestAnimationFrame intervals on an approximately 120 Hz display during the harness's scroll traces, so 8.3 ms is the display's own frame interval. S1 mount is the median of three mounts; heavier scenarios were mounted once.
| Scenario | Data | Mount | Frame p50 / p95 / p99 | DOM nodes | Heap after GC |
|---|---|---|---|---|---|
| S1 | 120 rows, 730 days, about 6,000 events | 23.2 ms | 8.3 / 9.1 / 9.3 ms | 2,506 | 11.2 MiB |
| S1, CPU 4× | Same | 104.6 ms | 16.1 / 25.2 / 25.9 ms | 2,506 | 11.2 MiB |
| S3 | 5,000 rows, 1,500 days, 200,021 events | 291.6 ms | 8.3 / 9.2 / 9.4 ms | 2,035 | 205.4 MiB |
| S3Dense | As S3 with no free night in any room, 1,634,510 events | 2,174.8 ms | 8.3 / 9.3 / 16.8 ms | 3,738 | 1,589.2 MiB |
Every scenario recorded zero React renders during scroll. These numbers come from one machine on one day, running the library's own demo application and its hooks; they are observations, not guarantees for your integration.
Limits
The DOM stays small at any size, but mount time and memory grow with the total number of events, because layout is computed for every expanded row when a grid is built. The S3Dense row above shows the ceiling: over two seconds to mount and about 1.6 GiB of heap for 1.6 million events. Well before that, load by range and keep only the dates people work with. There is no bulk mutation API yet, so very large live updates are best applied as one new events array. Many links also add paint work, since each paint considers every link.
Related guides: React integration, Controlled state, Time scales and zoom and Troubleshooting.