From new Chart() to Pixels: The Chart Lifecycle and Update Pipeline
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:
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
platformclass 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.,useEffectreturn function in React,onUnmountedin 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.