22 KiB
Embedding Vega-Lite in a real app: the parts the docs don't warn you about
Vega-Lite is a joy to author and a little treacherous to embed. The grammar is well documented; the runtime — view lifecycle, sizing, fonts, export, theming — is where you lose an afternoon to a blank chart with no error in the console.
This is a field guide from building a browser app that renders arbitrary, user-authored Vega-Lite specs live: type JSON on the left, see the chart on the right, export it, theme it, keep it responsive. Everything below is something that actually cost us time, with the fix and — more importantly — why it happens, so you can recognize the next variant of it.
It assumes vega-embed. If you hand-roll compile → parse → new View(), the same
issues apply; you just own more of the plumbing.
1. The view is the bug surface, not the spec
Every successful vegaEmbed() hands back a result.view — a live Vega View. It
owns timers, signal listeners, event handlers, and DOM. Render a new spec into the
same node without disposing the old view and the old one leaks: its listeners
keep firing, resources accumulate, and a long editing session slowly degrades.
let current: Awaited<ReturnType<typeof vegaEmbed>> | null = null;
async function rerender(node: HTMLElement, spec, config) {
current?.view.finalize(); // tear down the previous view FIRST
node.replaceChildren(); // drop any DOM the previous embed left behind
current = await vegaEmbed(node, spec, { config });
}
Two rules that pay for themselves:
finalize()before every re-embed, and on unmount. This is the single most important discipline.finalize()is not optional cleanup; it's how you avoid a zombie view.- Keep all
vegaEmbed()calls behind one small module. Components ask it to "draw this spec into this node" and get back a handle withdestroy(),toImageURL(),resize(). Nothing else importsvega-embedor touches aViewdirectly. This one boundary is what makes every other fix in this article land in exactly one place.
2. Container sizing breaks in two completely different ways
width: "container" / height: "container" is how Vega-Lite does responsive
sizing. It's also the source of the two most baffling bugs we hit, and they look
nothing alike.
Gotcha A — the chart collapses to zero width
Symptom: width: "container" charts render a sliver; height: "container" is
often fine. Classic "width is broken, height works" head-scratcher.
Cause: vega-embed injects .vega-embed { display: inline-block } into <head>
at runtime. Because it's injected late, it wins the cascade over a class you put
on that same element. inline-block shrink-wraps horizontally, and "container"
width reads host.clientWidth — which is now ~0. (Height survives because a tall
parent still gives the box a clientHeight.)
A nasty wrinkle: vega-embed only adds its responsive chart-wrapper element —
the thing its own width: 100% rule targets — when the actions menu is enabled.
If you pass actions: false (you probably do; see §7), that path is dead and your
element is branded inline-block directly, with nothing fixing it.
Fix: embed into a dedicated inner host with a static className, nested inside
an outer frame you control. Size the inner host with a two-class selector so you
out-specify .vega-embed:
/* one class loses to .vega-embed; two classes win */
.fitWidth .host {
width: 100%;
}
Keep the inner host's class static so React (or whatever owns the DOM) never re-reconciles it and stomps Vega's runtime classes.
Gotcha B — the chart never follows a resize
Symptom: a responsive chart sizes correctly on first render, then ignores the pane being dragged wider.
Cause: Vega-Lite compiles "container" sizing into width/height signals that
re-read containerSize() only on a window:resize event. Two consequences
people rediscover the hard way:
view.resize()does not re-measure. It re-runs layout with the stale size.- A pane drag (a splitter, a layout change) fires no
window:resize, so nothing re-fits on its own.
Fix: observe the host with a ResizeObserver and synthesize the event Vega is
actually listening for.
const ro = new ResizeObserver(() => {
window.dispatchEvent(new Event('resize')); // the mechanism, not a hack
});
ro.observe(host);
This is the documented mechanism, not a workaround — it's literally what the Vega
editor does. ResizeObserver callbacks are frame-batched, so it tracks a drag
smoothly with no debounce. Bonus: only the container-bound dimension carries the
resize handler, so a width-only chart re-fits width and leaves height natural for
free, with zero bookkeeping.
3. Fonts must finish loading before you render — any renderer
This one is invisible until you ship a custom font. The chart renders, the text looks slightly wrong (spacing off, labels colliding or over-padded), and it sometimes fixes itself on the next edit.
Cause: Vega measures every text label with canvas measureText regardless of
renderer — SVG, canvas, even the headless 'none' renderer runs a layout pass. If
a web font is still loading when you embed, the entire chart is laid out with
fallback font metrics. When the real font swaps in, the glyphs change but the
layout was already computed against the wrong widths.
Fix: gate the render on the fonts the spec actually references.
async function ensureFontsLoaded(families: string[]) {
if (!document.fonts?.load) return; // no-op in tests / old browsers
const loads = families.flatMap((f) =>
['400', '600', '700'].map((w) => document.fonts.load(`${w} 16px ${f}`)),
);
// allSettled, not all: a missing face (offline, 404, a system family with no
// @font-face) is EXPECTED — degrade to fallback metrics, never fail the chart.
await Promise.race([
Promise.allSettled(loads),
new Promise((r) => setTimeout(r, 3000)), // bound a slow first fetch
]);
}
Two judgment calls worth copying: use allSettled (a font failing to load is not a
chart error — it's a render-with-fallback), and cap the wait with a timeout so a
slow network never freezes the preview. A cached face resolves near-instantly; the
timeout only ever bounds the very first fetch of an uncached subset.
4. SVG vs canvas is a real performance cliff, and canvas has a silent ceiling
The default renderer: 'svg' is the right call almost always — crisp at any zoom,
inspectable, copyable, themeable. But SVG renders one DOM node per mark. A chart
with thousands of marks (say one bar per row of a 10k-row dataset) costs seconds
of main-thread layout and paint per render. We measured ~6.5s of paint on ~10k rows —
and the freeze lands after the chart first appears, because the browser paints the
SVG tree lazily. The tab locks up holding a chart that looks done.
Switch many-mark charts to renderer: 'canvas': a single node, painted in
milliseconds. The raster trade-off (not crisp on zoom) is invisible for an ephemeral
preview, and — crucially — image export is renderer-agnostic (§7), so you lose
nothing downstream.
But canvas has its own trap: a hard maximum dimension. Browsers cap a canvas backing store at ~32,767px per side (less on Safari, which is also area-bound). Past that, the canvas fails to allocate and draws nothing — no error, no exception, just a blank surface and sometimes a null 2D context. A tall categorical chart (hundreds of natural-height rows) blows past this easily.
Fix: before committing to canvas, run a headless layout probe and read the resolved
size. The 'none' renderer computes layout without allocating a canvas:
const probe = await vegaEmbed(detachedDiv, spec, { renderer: 'none', config });
const height = probe.view.height();
probe.view.finalize();
const limit = 32767 / (window.devicePixelRatio || 1); // backing store is dpr×
if (height > limit) throw new ChartTooLargeError(height, limit);
Now you can tell the user the real cause ("this chart is 50,000px tall") instead of handing back a blank box. SVG has no such cap — it just gets slow — so the probe is canvas-only.
5. Exporting an image has three sharp edges
You'd think view.toImageURL() is the export story. It isn't, quite.
Retina blur. toImageURL's scaleFactor ignores devicePixelRatio. A naive
"1×" PNG export comes out at half resolution on a 2× display — soft, obviously
wrong next to the crisp on-screen chart. Multiply the scale by dpr yourself:
const dpr = window.devicePixelRatio || 1;
const canvas = await view.toCanvas(scale * dpr); // "1×" now matches the screen
Transparent background. If your theme sets background: 'transparent' (you
probably do, so the chart shows the pane color through it — §6), every export is
also transparent. Usually not what someone wants in a PNG. Composite an opaque color
under the canvas, and inject a full-bleed <rect> as the first child of the root
<svg> for the vector path:
svg = svg.replace(/(<svg\b[^>]*>)/, `$1<rect width="100%" height="100%" fill="${bg}"/>`);
SVG drops your fonts. view.toSVG() serializes only the font-family name.
Open that SVG anywhere the font isn't installed and it falls back to a system font.
If the font is one your users uploaded, embed it as a base64 @font-face rule inside
a <style> at the top of the SVG:
const rule = `@font-face{font-family:"${family}";src:url(${dataUri}) format("woff2");}`;
// SVG is XML, and a family name can contain & or <, so wrap the CSS in CDATA —
// and defensively split the one sequence CDATA can't contain:
const css = rule.replace(/]]>/g, ']]]]><![CDATA[>');
svg = svg.replace(/(<svg\b[^>]*>)/, `$1<style type="text/css"><![CDATA[${css}]]></style>`);
PNG needs none of this — the raster already baked the glyphs in. Only the vector format leaks the font dependency.
One nice property to lean on: export is renderer-agnostic. view.toCanvas() and
view.toSVG() draw to their own off-screen surface, independent of how the chart is
displayed. So you can show an SVG chart on screen and still export a high-res PNG, or
show a canvas preview (§4) and still export a clean SVG.
6. Theme is a config you merge at embed time — and Vega is picky about it
A Vega-Lite config object styles every chart globally: fonts, axis colors, background, the categorical palette. The right model is to keep the config out of the user's stored spec and inject it at embed time, so the same spec re-themes for free when the UI flips light/dark:
await vegaEmbed(node, spec, { config: chartConfigFor(theme) });
Three things that bit us:
- The spec wins, key by key. Vega-Lite merges your injected
configunder the spec's ownconfig(mergeConfig(opt.config, spec.config)). That's the behavior you want — a snippet can override or opt out locally — but know it: you can't force a style the spec contradicts. - Don't rebuild a config from a fixed schema. If you let users edit a config
through structured controls, mutate the config object in place; don't reconstruct
it from a known set of keys. Preset themes (and Vega proper) carry Vega-layer
keys —
symbol,shape,path,group— that aren't in the Vega-LiteConfigtype but are forwarded to Vega at render. A rebuild silently drops them. - A bare scheme name passes compile but fails at render. Writing
range: { category: "tableau20" }(a bare string) survives Vega-Lite compilation and then Vega rejects it at render with "Unrecognized scale range value" — and blanks the chart. The accepted form is the object:range: { category: { scheme: "tableau20" } }. This compile-passes/render-fails split is a recurring Vega theme; when a chart goes blank with a console error but no compile error, suspect a value that's structurally valid JSON but semantically wrong for the runtime.
If you want themes that follow the system light/dark, keep exactly one function
that maps (selection, uiTheme) → config. Every render resolves through it; nothing
else decides a chart's styling. (We also offer the vega-themes package's presets
verbatim — it's already in your tree as a vega-embed dependency, so the famous
FiveThirtyEight / Excel / Carbon looks are free.)
7. actions: false does more than hide a menu
You'll almost certainly want actions: false — the built-in "Save as / View Source /
Open in Vega Editor" overlay doesn't belong on most embeds, and you'll provide your
own export. Just know two side effects:
- As noted in §2, it removes the responsive
chart-wrapper, so you own host sizing. - You also give up the built-in PNG/SVG export, so build your own through the view (§5). That's a feature, not a cost — you get dpr-correct, background-filled, font-embedded exports the built-in menu never gave you.
And for tooltips: pass tooltip: { disableDefaultStyle: true } so vega-tooltip
doesn't inject its own light/dark stylesheet. The tooltip element (#vg-tooltip- element) is appended to <body>, so once the default style is gone you style it
entirely from your own CSS — and because it lives under <html>, it inherits your
[data-theme] cascade for free. vega-tooltip still handles positioning and the
.visible toggle; you just supply the look.
8. Field names with dots are not what you think
If you construct specs from data-derived column names (a chart builder, an
auto-encoding helper), this will bite you. Vega-Lite treats ., [, and ]
inside a field: as nested-property accessors: field: "user.age" reads
row.user.age, not a column literally named "user.age". Real-world CSVs have
columns like Price ($) or 2021.Q3 all the time.
const escapeField = (name: string) => name.replace(/([.[\]])/g, '\\$1');
encoding.x = { field: escapeField(columnName), type: 'quantitative' };
Escape every data-derived name before it lands in any field-position key — field,
as, groupby, tooltip fields, the lot. (For specs a user hand-authored, escaping
is their responsibility; don't rewrite their field: values.)
9. Never mutate the spec you render
Rendering should be a pure function of (spec, config). If your pipeline rewrites the spec on the way to the view — resolving dataset references to inline values, applying a responsive sizing mode, escaping fields — do it on a deep copy:
const prepared = structuredClone(userSpec);
// ...mutate `prepared` freely: resolve refs, set width:"container", etc.
await vegaEmbed(node, prepared, { config });
// userSpec is untouched — what the user sees in the editor is still what they wrote.
The moment rendering mutates the stored spec, you get spooky action: a fit-mode
toggle permanently rewrites the user's width, an export inlines a 2MB dataset into
the document they're editing. Keep the transform pure and copy-first, and it stays
unit-testable without a DOM as a bonus.
A related subtlety: a "fit to container" mode that sets width: "container" should
also delete the spec's explicit height (and vice-versa) so the unconstrained
dimension recomputes naturally. Which means a surface that lets the user type an
explicit width/height must opt out of fit mode while they do — the two fight over
the same keys.
10. Live editing: debounce the input, guard the output
For a live preview that re-renders as the user types, two independent concerns:
Debounce edit → state, not state → render. Re-rendering must never compete with typing. Debounce the editor's text changes (we make the delay user-configurable, ~500–5000ms); render only after a pause. But render immediately for non-typing changes — loading a different spec, a theme flip, a fit-mode toggle. The debounce exists for keystroke churn and nothing else; detect "this was a keystroke" by elimination (the text changed but the document identity didn't).
Guard against out-of-order renders. vegaEmbed/runAsync is async, so a slow
render can resolve after a newer one already mounted. A bare debounce doesn't cover
this. Stamp each render with a generation token and let only the latest one win:
let generation = 0;
async function render() {
const mine = ++generation;
const handle = await renderSpec(node, spec, config);
if (mine !== generation) {
handle.destroy();
return;
} // a newer render superseded us
current = handle;
}
And keep the last good chart on screen while the next render computes — overlay a subtle busy indicator rather than blanking the pane. A pane that flickers to empty on every keystroke feels broken even when it's fast.
11. Errors: a blank spec is not an error, and recovery should be automatic
Three stages fail, and you want them distinguishable in the message: JSON parse ("Invalid JSON: …"), spec preparation ("Dataset not found: …"), and embed itself ("Rendering error: …", the Vega-Lite compile or Vega runtime failure). Funnel all three to one error field the preview reads.
The behaviors that make it feel solid:
- Empty/blank text renders nothing — a clean pane, not an error. Finalize the current view, clear the error, stop.
- Every successful render clears the error. Recovery is then automatic: the next valid edit re-renders and wipes the message. No retry button, no reload.
- Keep the last good chart visible under a parse error if you can, so a half-typed keystroke doesn't strobe the whole pane.
- Wrap
runAsync/embed in try/catch and finalize on failure. Vega won't catch runtime errors for you, and a half-initialized view leaks if you don't finalize it. - Don't dump a raw stack trace. Give the reason plus a hint ("check your JSON and that the spec is valid Vega-Lite").
12. If you also embed an editor (Monaco) — wire it yourself
Optional, but if you're putting users in front of raw spec JSON you'll want schema validation and autocomplete. The non-obvious parts:
- Bundle the schema; never fetch it.
import schema from 'vega-lite/vega-lite-schema.json'and register it once, globally, via the JSON language service (setDiagnosticsOptions). Version-locked to your installed Vega-Lite, offline-safe, no runtime network call. SetenableSchemaRequest: falseso the worker can't go fetch an unbundled$schemaURL behind your back. - Bind by
fileMatch, not by the doc's$schemavalue. If you key validation off the$schemaline, a spec without one gets zero validation and zero completions. Match your model URIs instead so it always works. - Monaco workers are on you. With a CDN loader they're automatic; self-hosted,
you must set
MonacoEnvironment.getWorkerto return the JSON worker for label'json'and the editor worker otherwise. No worker means no squiggles and no completions — and no error telling you why. quickSuggestions: { strings: true }. Vega-Lite enum values ("bar","quantitative") live inside JSON strings, where Monaco disables autocomplete by default. Without this, completions silently never appear.- Two layers, two tiers. The Monaco worker gives inline squiggles; a separate
ajvpass can feed a richer error pane. Sort findings into fatal (syntax / compile / runtime errors that suppress the chart) and advisory (schema-validation warnings that don't). Vega-Lite emits plenty of benign warnings; treating them as fatal hides specs that render fine. - ajv has its own gotchas: construct it with
strict: false, add the draft-06 meta-schema (the VL schema is draft-06; ajv 8 defaults newer), register a no-opcolor-hexformat, and compile the validator once at module load — the schema is multi-megabyte and compiling per keystroke is a real perf sink.
The short version
If you skim one thing, skim this:
| Trap | Fix |
|---|---|
| Re-embedding leaks the old view | view.finalize() before every re-embed and on unmount |
width:"container" collapses to ~0 |
Inner host + out-specify .vega-embed { inline-block } with a 2-class selector |
| Chart won't follow a resize | ResizeObserver → window.dispatchEvent(new Event('resize')), not view.resize() |
| Custom font lays out wrong | document.fonts.load(...) (allSettled + timeout) before embed; metrics are measured regardless of renderer |
| Many-mark SVG freezes the tab | Switch to renderer: 'canvas'; probe size first — canvas fails silently past ~32k px |
| Export looks soft on Retina | Multiply scale by devicePixelRatio |
| Export is transparent / loses fonts | Composite a bg color; embed @font-face (CDATA) in the SVG |
| Bare scheme name blanks the chart | Use range: { category: { scheme: "…" } }, not a bare string |
| Dotted column names misread | Escape .[] in every data-derived field: |
| Stale async render clobbers a fresh one | Render-generation token; only the latest wins |
| Rendering mutates the user's spec | Transform on a structuredClone copy |
None of these are exotic. They're the gap between "it works in the demo" and "it holds up under a 10k-row dataset, a custom font, a dragged pane, and a Retina export." Vega-Lite is excellent; it just expects you to know where its runtime edges are. Now you do.