65,535 Vertices: When Text Export Breaks Your Renderer

Render a simple rectangle. Works.

Render a star shape. Works.

Render flattened text saying "Hello World". Crash.

The error: vertex index out of bounds.

The problem: we used Uint16 for vertex indices. Maximum value: 65,535.

The flattened text had 100,000+ vertices.

The Problem

Vector networks store vertices in an array, segments reference them by index:

struct Segment {
  uint16_t fromVertex;  // Index into vertices array
  uint16_t toVertex;
};

uint16_t can represent 0–65,535. Seemed like plenty. Who needs 65,000 vertices in a single shape?

Figma users exporting flattened text, apparently.

When you "flatten" text in Figma, it converts font outlines to vector paths. A single letter might have dozens of curves. A word has hundreds. A paragraph has tens of thousands.

Export a text-heavy design. Vertices: 100,000+. Our renderer: "Nope."

First Attempt: Validate and Reject

Add bounds checking:

uint32_t addVertex(float x, float y) {
  if (fVertices.size() >= 65535) {
    throw std::runtime_error("Too many vertices");
  }
  fVertices.push_back({x, y});
  return fVertices.size() - 1;
}

This prevented crashes. But it also prevented rendering valid Figma exports.

Not a solution—a band-aid.

The Solution: Migrate to Uint32

Change all vertex indices from uint16_t to uint32_t:

struct Segment {
  uint32_t fromVertex;  // Now supports 4 billion indices
  uint32_t toVertex;
};

Seems simple. Except vertex indices are everywhere:

The Migration Process

Step 1: Change C++ data structures:

// Before
skia_private::TArray fSegmentIndices;

// After
skia_private::TArray fSegmentIndices;

Step 2: Update WASM bindings:

// Before
typedef uintptr_t WASMPointerU16;

// After (add new type)
typedef uintptr_t WASMPointerU32;

// Update binding signatures
.function("_addSegments", optional_override([](
    CfVectorNetwork& vn,
    WASMPointerU32 fromToPtr,  // Changed from WASMPointerU16
    int fromToLength,
    // ...
)))

Step 3: Update JavaScript arrays:

// Before
const segments = new Uint16Array([0, 1, 1, 2, 2, 3]);

// After
const segments = new Uint32Array([0, 1, 1, 2, 2, 3]);

Step 4: Update memory allocation:

// Before: 2 bytes per index
var ptr = CanvasKit._malloc(count * 2);

// After: 4 bytes per index
var ptr = CanvasKit._malloc(count * 4);

Step 5: Fix packed data formats:

Many functions packed multiple values into single arrays. Changing from Uint16 to Uint32 doubled the memory layout size:

// Before: [from0, to0, from1, to1, ...] - 2 bytes each
// After: [from0, to0, from1, to1, ...] - 4 bytes each
// Same logical structure, different memory size

All the memory management code needed updating.

The Memory Cost

Going from 16-bit to 32-bit indices doubles the index storage:

Before:

After:

For typical scenes (hundreds of segments), this is negligible. For complex scenes (thousands of segments), it's still <1MB.

The trade-off: 2× index memory for unlimited vertex count.

Worth it to support real-world Figma exports.

Results

Vector networks now support up to 4,294,967,295 vertices (2^32). Flattened text renders correctly regardless of complexity.

The migration touched:

Not a huge change, but pervasive—indices are used everywhere in a vector graphics system.

The lesson: don't assume your "generous" limits are actually generous. Real-world data breaks assumptions. Flattened text routinely exceeds 65K vertices.

Design for the actual data, not the expected data.


Read next: Loop Detection: Finding Holes in Polygon Soup - Unordered segments to renderable topology.