Banana

Engineering

Building a calendar renderer we can measure

By Banana team · Published

A calendar can look smooth with a handful of appointments and still struggle when the day fills up. Overlapping events narrow the columns. An overnight event appears on two dates. A drag must keep working while the underlying data changes. Meanwhile, the rest of the app may be syncing, playing audio, or loading retained history.

Building Banana’s calendar renderer has meant working on two things together: the renderer itself and a way to tell whether a change actually helps. Reusable native cells have reduced memory in our Simulator comparisons. Preparing only the artwork a viewport needs has reduced measured drawing work. Neither result, by itself, proves that scrolling meets our frame budget.

Here is how we split the problem, what we measured, and where our evidence still falls short.

Start with three different clocks

We use three performance requirements:

  • Frame work: a 120 Hz display has about 8.3 milliseconds between refreshes.
  • Direct interaction: target input feedback under 10 milliseconds at the 95th percentile, with a 20-millisecond maximum.
  • Other local actions: aim for roughly 32–50 milliseconds to open a detail or switch a view, with a 100-millisecond ceiling.

These are our targets, not a statement that every current screen meets them.

The distinction matters because the tools measure different things. A layout function finishing in two milliseconds does not mean the GPU delivered the next frame on time. An XCTest swipe includes gesture injection, motion, and deceleration; its duration is not a measurement of input-to-visible latency. Even a display-link callback tells us about callback scheduling rather than proving what appeared on the screen.

We keep component cost, functional correctness, memory, and actual frame/input measurements separate. Combining them into one green “performance” label would hide too much.

Give the viewport ownership of view lifetimes

Our reference Day implementation used a scroll view containing the day’s event view tree. The native path moves event content into reusable collection-view cells. On iPhone and iPad that means UIKit’s UICollectionView; the Mac implementation uses AppKit’s NSCollectionView rather than assuming the UIKit bridge works on macOS.

We separated four responsibilities:

  1. Calendar rules determine occurrence identity, overlap lanes, clipping, and frames.
  2. The native viewport decides which event views need to exist for the visible region.
  3. Artwork draws the event’s title, time, color, and state.
  4. The interactive cell owns activation, accessibility, gestures, and the connection to edit actions.

The core of our event descriptor looks like this, with movement and clipping fields omitted:

struct CalendarViewportItem {
    let id: String
    let frame: CGRect
    let content: () -> AnyView
}

The closure is the important boundary: describing an event does not require constructing its SwiftUI view immediately. The collection layout answers the native framework’s rectangle queries; the cell constructs the existing event content when needed. On iOS, UIHostingConfiguration lets that content remain SwiftUI inside a reusable UIKit cell. Apple’s custom collection-layout APIs supply the viewport integration.

This does not make all calendar work proportional to the visible cells. Our fixed-frame layout still scans cached attributes for intersections, and placement geometry is a separate cost. View reuse reduces the number of live event views; it does not magically bound retained data or remove layout work.

Week adds another identity problem: the same overnight occurrence can occupy cells in multiple day columns. We namespace the viewport identity by day while keeping the action attached to the original occurrence. Otherwise, reuse can confuse two visible pieces of one event with two independent events.

Keep drawing separate from behavior

An immutable paint description holds the inputs needed to draw an event. It does not carry a provider object, a database store, or a pending write operation. That boundary lets us compare the existing SwiftUI artwork with prepared rendering without moving the event’s behavior into an image cache.

The prepared-artwork path is enabled in our separate Beta configuration and can be selected for controlled development comparisons. It is distinct from adopting a native collection viewport. We can test the viewport with reference artwork, then test artwork preparation separately.

Cached artwork needs a more complete identity than an event ID. A title edit, changed width, different appearance, or accessibility text size can require a new result. Preparation also has to handle cancellation, eviction, and consumers releasing images. A late result from an obsolete request must not repaint a reused cell with the wrong event.

That is why lifecycle tests belong beside rendering tests. A cache that is fast only while it grows forever is not a finished cache.

Use workloads that can expose the problem

A quiet demo calendar is useful for explaining the interface. It is a weak stress test.

Our persistent component workload specification covers 1, 7, 30, and 90 days of synthetic retained history, with daily loads of 2,000 emails, 100 calendar events, 1,000 notes, and 10,000 podcast episodes. Separate redraw probes deliver 100 playback-clock updates at 10 Hz to check whether unrelated views recompute. These are stress fixtures, not claims about a typical person’s usage.

We also use a varied calendar fixture with 12 calendars and 30 different daily patterns. Repeating the same convenient day can miss the cases that hurt: dense overlaps, long events, clipping, and different visible working sets.

For a renderer comparison, the policies must use the same workload and binary. We retain the old renderer as an explicit development comparison path and alternate runs—for example, reference, native, native, reference. This helps expose run-order effects, although it cannot eliminate host noise. Cold, warm, and eviction phases answer different questions and stay separate.

What the native Week comparison showed

One October 10 Simulator comparison used the same optimized application, an unchanged 30-day legacy stress workload, and three scroll iterations per run. That fixture is separate from the broader retained-history matrix above. These were the recorded median process-memory values:

Run Viewport Median process memory, kB
1 Reference 239,291
2 Native 198,462
3 Native 198,134
4 Reference 239,504

That is approximately 41 MB, or 17%, less process memory in this particular Simulator comparison. It is useful evidence for reducing the live view working set.

It is not evidence of faster scrolling. The recorded XCTest gesture durations did not demonstrate a speedup, and that comparison did not export frame-deadline or input-to-visible measurements. The displayed week also sat at a fixture boundary; it was not seven fully populated days. We cannot turn those numbers into a calendar capacity ceiling or a physical-device result.

What the artwork experiment showed

A separate Day experiment compared prepared artwork in the reference and native viewports, again in reference/native/native/reference order:

Run Viewport Rasterizations Total measured raster work
1 Reference 107 96.231 ms
2 Native 45 39.168 ms
3 Native 44 37.263 ms
4 Reference 106 93.871 ms

The native viewport requested less initial artwork. Measured consumer image ownership fell from 2,652,480 bytes to 1,113,920 bytes in those runs.

The total-work column adds together multiple queued jobs. It is not one main-thread stall, a first-frame completion time, or a claim that each drawing operation became faster. Individual rasterization maxima did not improve in this comparison. The useful result is that we did less work, not that a stopwatch proved the complete interface faster.

These measurements are historical experiment results from October 10, not fresh benchmarks of today’s release build.

A failed test can reveal a bad assumption

The first native Week candidate failed while the test tried to locate an event. Investigating the hierarchy showed that the reference renderer exposed the chosen event outside its day column. The native renderer clipped it correctly, so the old scroll anchor disappeared.

The old test had also opened a different event after scrolling. It could pass without proving the intended event was reachable.

We changed the protocol to search for and open the specific event, verify its detail title, and then use a shared clock position for the scrolling comparison. The workload stayed unchanged, and the earlier failures remained in the record.

An iPad reuse check exposed another assumption. It expected offscreen cells, but the larger viewport showed almost the entire day: all 59 descriptors could legitimately be visible. The corrected setup zoomed into a partial-day viewport before checking reuse. Changing the setup made the test exercise its stated condition; simply relaxing the assertion would not have done that.

These were harness corrections, not renderer speedups.

Fast edits still need durable failure handling

Dragging an event raises a different correctness question: what happens if the provider rejects the move?

The design needs to preserve the pending operation, restore or reconcile the displayed event, and offer a retry that survives relaunch. Cell reuse cannot silently replace the event being dragged. A prepared image cannot become the owner of the event’s actions or accessibility semantics.

Our recorded default Week tests include an isolated rejected move, restoration, relaunch, and another rejected retry. Those checks prove that tested failure path. They do not prove a successful write against a live Google account, every recurring-event case, or physical interaction latency.

Performance work that loses an edit is a regression, even if its timing is excellent.

Where the work stands

Native Day and Week viewports became the iPhone/iPad defaults during this work, and the Mac has its own AppKit collection host. The historical component and UI results cover specific builds and workloads. We still have gaps in physical frame/input evidence and retained failing measurements; a passing Simulator cohort does not close them.

We have also paused automatic GitHub performance runs while the harness is being made ready. The test code and recorded results remain. This article describes the engineering and the evidence, not a claim that the performance suite is ready as a release gate.

The next useful evidence is a reproducible trace on the actual device: the same workload, build, cache state, display behavior, and concurrent activity, with both frame work and input-to-visible response measured. Until then, the claims we can defend are narrower: fewer live views, lower measured memory in a controlled comparison, less artwork preparation, and better tests for the cases a smooth demo can miss.

← All posts · Questions? Contact us