Read OSS

From new Chart() to Pixels: The Chart Lifecycle and Update Pipeline

Intermediate

Prerequisites

  • Article 1: Architecture Overview
  • Basic understanding of HTML5 Canvas API
  • Familiarity with requestAnimationFrame

From new Chart() to Pixels: The Chart Lifecycle and Update Pipeline

Now that we understand how Chart.js is organized (from Part 1), let's trace the most important path through the codebase: what happens from the moment you call new Chart(canvas, config) to the first pixels appearing on screen. The Chart class in core.controller.js is the central orchestrator—a ~1,250-line file that coordinates Config, Platform, PluginService, DatasetControllers, Scales, and Layouts into a coherent update-render cycle.

The Chart Constructor: Bootstrapping a Chart Instance

The constructor lives at line 123 of core.controller.js and does a surprising amount of work in a single synchronous call:

src/core/core.controller.js#L103-L195

sequenceDiagram
    participant Dev as Developer
    participant Chart as Chart Constructor
    participant Config as Config
    participant Platform as Platform
    participant Animator as Animator
    participant Init as _initialize()

    Dev->>Chart: new Chart(canvas, config)
    Chart->>Config: new Config(userConfig)
    Chart->>Chart: getCanvas(item)
    Chart->>Chart: Check for existing chart on canvas
    Chart->>Config: createResolver(chartOptionScopes())
    Chart->>Platform: new (detectPlatform())
    Chart->>Platform: acquireContext(canvas)
    Chart->>Chart: Assign id, ctx, canvas, dimensions
    Chart->>Chart: new PluginService()
    Chart->>Chart: Store in instances[this.id]
    Chart->>Animator: listen(this, 'complete', ...)
    Chart->>Animator: listen(this, 'progress', ...)
    Chart->>Init: _initialize()
    Chart->>Chart: update() if attached

Several details deserve attention here. First, the instances object at line 66 is a plain object (not a WeakMap), keyed by a numeric ID generated via uid(). This means every Chart instance stays reachable until destroy() is explicitly called—a common source of memory leaks in SPAs where developers forget cleanup.

Second, the constructor immediately checks if the canvas already hosts a Chart (getChart(initialCanvas)). If so, it throws an error rather than silently overwriting. This defensive check prevents the subtle bugs that arise when two chart instances try to share a canvas.

Third, the PluginService instance is created per-chart (this._plugins = new PluginService()), not shared globally. This allows different charts on the same page to have different plugin configurations.

Platform Detection and Abstraction

Chart.js abstracts the rendering environment through a Platform class. The detection logic is elegantly simple:

src/platform/index.js#L6-L11

flowchart TD
    START["_detectPlatform(canvas)"] --> DOM_CHECK{"_isDomSupported()?"}
    DOM_CHECK -->|No| BASIC["BasicPlatform<br/>(Node.js / Workers)"]
    DOM_CHECK -->|Yes| OFFSCREEN{"canvas instanceof OffscreenCanvas?"}
    OFFSCREEN -->|Yes| BASIC
    OFFSCREEN -->|No| DOM["DomPlatform<br/>(Browser)"]

The DomPlatform handles CSS-to-pixel ratio conversion, resize observation via ResizeObserver, and DOM event normalization—including a mapping from touch/pointer events to Chart.js's mouse-based event model:

src/platform/platform.dom.js#L14-L31

The BasicPlatform provides no-op implementations for event binding and resize observation, making Chart.js usable in web workers and Node.js environments (with a Canvas polyfill like node-canvas).

Tip: You can bypass platform auto-detection entirely by passing a platform class in your config: new Chart(canvas, { platform: MyCustomPlatform, ... }). This is useful for testing or custom rendering environments.

The update() Pipeline: Config to Canvas

The update() method is the heart of Chart.js. Every data change, option change, or resize funnels through this 62-line method:

src/core/core.controller.js#L475-L537

flowchart TD
    A["config.update()"] --> B["Resolve chart options"]
    B --> C["_updateScales()<br/>Remove old → ensure IDs → build/update"]
    C --> D["_checkEventBindings()"]
    D --> E["plugins.invalidate()"]
    E --> F{"beforeUpdate hook<br/>cancelable?"}
    F -->|cancelled| STOP["Return"]
    F -->|proceed| G["buildOrUpdateControllers()"]
    G --> H["beforeElementsUpdate hook"]
    H --> I["buildOrUpdateElements<br/>for each dataset"]
    I --> J["_updateLayout(minPadding)"]
    J --> K["Reset new controllers<br/>(animation start points)"]
    K --> L["_updateDatasets(mode)"]
    L --> M["afterUpdate hook"]
    M --> N["Sort layers by z-index"]
    N --> O["Replay last event"]
    O --> P["render()"]

The ordering of operations is deliberate and critical. Scales must be built before controllers, because controllers need scales to determine axis mapping. Controllers must be built before layout, because the layout engine needs to know what boxes (scales, legends) exist. New controllers are reset after layout, because the reset establishes animation starting points that depend on final scale positions.

The mode parameter (e.g., 'resize', 'reset', 'active') flows through to controllers so they can optimize specific update paths. For example, during a resize, controllers might skip data re-parsing and only recalculate element positions.

Layout Engine and Box Model

The layout system in core.layouts.js implements an iterative constraint-solving algorithm. Every visual component that needs space—scales, legends, titles—implements the LayoutItem interface and is treated as a "box" to be positioned:

src/core/core.layouts.js#L345-L454

flowchart TD
    START["layouts.update(chart, width, height)"] --> BUILD["buildLayoutBoxes(chart.boxes)"]
    BUILD --> NOTIFY["Notify boxes: beforeLayout()"]
    NOTIFY --> PARAMS["Compute params:<br/>padding, availableWidth, availableHeight"]
    PARAMS --> FULL["fitBoxes(fullSize boxes)"]
    FULL --> VERT["fitBoxes(vertical boxes)"]
    VERT --> HORIZ["fitBoxes(horizontal boxes)"]
    HORIZ --> CHANGED{"Chart area changed?"}
    CHANGED -->|Yes| REFIT["Re-fit vertical boxes"]
    CHANGED -->|No| PAD["handleMaxPadding"]
    REFIT --> PAD
    PAD --> PLACE_LT["placeBoxes(leftAndTop)"]
    PLACE_LT --> PLACE_RB["placeBoxes(rightAndBottom)"]
    PLACE_RB --> AREA["Set chart.chartArea"]
    AREA --> CHARTAREA_BOXES["Update chartArea boxes<br/>(e.g., radial scale)"]

The key insight is the recursive fitBoxes function at line 179. Each box is asked to update() with its available width/height, and the resulting dimensions are subtracted from the chart area. If fitting horizontal boxes changes the available width, vertical boxes are re-fitted. This converges because each iteration can only shrink the available space.

The ASCII art comment at lines 369-389 is worth reading in the source—it documents the visual layout model with a diagram showing how T1, L1, L2, R1, B1, B2, and the ChartArea interact.

Rendering: The draw() Method and Layer System

When it's time to paint, Chart.js separates scheduling (the render() method) from painting (the draw() method):

src/core/core.controller.js#L683-L732

render() checks if animations are active. If so, it delegates to the Animator singleton, which will call draw() on each animation frame. If no animations are pending, draw() is called directly.

The draw() method implements a z-indexed layered rendering system:

sequenceDiagram
    participant Draw as draw()
    participant Layers as Layer Stack
    participant Datasets as _drawDatasets()
    participant Canvas as Canvas Context

    Draw->>Draw: Handle pending resize
    Draw->>Canvas: clear()
    Draw->>Draw: beforeDraw plugin hook
    loop Layers where z ≤ 0
        Draw->>Canvas: layer.draw(chartArea)
    end
    Draw->>Datasets: _drawDatasets()
    Note over Datasets: Back-to-front by order/index
    loop Layers where z > 0
        Draw->>Canvas: layer.draw(chartArea)
    end
    Draw->>Draw: afterDraw plugin hook

Each scale provides its own _layers() method that returns layer objects with z-indices. Grid lines typically use z: -1 (drawn behind datasets), while tick labels use z: 0. This allows scale components to interleave with dataset rendering without special-casing.

Datasets themselves are drawn back-to-front (metasets.length - 1 down to 0), so datasets with higher indices appear on top. Each dataset is clipped to its scale boundaries before drawing, preventing data points from rendering outside the chart area.

The Animator Singleton

The Animator drives the requestAnimationFrame loop for all charts on the page:

src/core/core.animator.js#L12-L214

sequenceDiagram
    participant Chart as Chart.render()
    participant Animator as Animator Singleton
    participant RAF as requestAnimationFrame
    participant Anim as Animation Items

    Chart->>Animator: start(chart)
    Animator->>Animator: anims.running = true
    Animator->>RAF: _refresh() → requestAnimFrame
    RAF->>Animator: _update(date)
    loop Each chart in _charts Map
        loop Each active animation item
            Animator->>Anim: item.tick(date)
        end
        Animator->>Chart: chart.draw()
        Animator->>Animator: _notify(chart, 'progress')
    end
    Note over Animator: If items remain, schedule next frame
    Animator->>RAF: _refresh() (loop)
    Note over Animator: When items empty → _notify 'complete'

The Animator uses a Map<Chart, AnimState> to track animation state per chart. When start() is called, it sets running = true and kicks off the rAF loop with _refresh(). The _update() method iterates all charts, ticks their animations, and calls chart.draw() for any chart with active animations.

Completed animation items are removed efficiently: instead of splicing, the item is replaced with the last element and pop() is called—an O(1) operation versus O(n) for splice.

Destruction and Cleanup

The destroy() method reverses everything the constructor set up:

src/core/core.controller.js#L937-L955

sequenceDiagram
    participant Dev as Developer
    participant Chart as Chart
    participant Plugins as PluginService
    participant Animator as Animator
    participant Platform as Platform

    Dev->>Chart: destroy()
    Chart->>Plugins: notifyPlugins('beforeDestroy')
    Chart->>Chart: _stop() → animator.stop + remove
    Chart->>Chart: Destroy all dataset metas
    Chart->>Chart: config.clearCache()
    Chart->>Chart: unbindEvents()
    Chart->>Chart: clearCanvas()
    Chart->>Platform: releaseContext()
    Chart->>Chart: canvas = null, ctx = null
    Chart->>Chart: delete instances[this.id]
    Chart->>Plugins: notifyPlugins('afterDestroy')
    Note over Plugins: Also triggers 'stop' and 'uninstall'

The sequence is carefully ordered: plugins are notified before cleanup (so they can access the chart state) and after (so they can release their own resources). The _stop() call halts all animations and removes the chart from the Animator's map. Finally, the chart is removed from the global instances object, making it eligible for garbage collection.

Tip: In single-page applications, always call chart.destroy() in your component's cleanup lifecycle (e.g., useEffect return function in React, onUnmounted in Vue). Failing to do so leaks the canvas context, event listeners, and the chart instance itself.

Bridging to the Next Article

We've seen that update() calls config.createResolver(config.chartOptionScopes(), ...) to resolve chart options. But what actually happens inside that resolver? In the next article, we'll dive into Chart.js's most sophisticated subsystem: the multi-layer option resolution engine built on JavaScript Proxies, with its scope chains, scriptable options, and recursive fallback routes.