Optimizing Konva Performance with Layer Caching and Tiling

Introduction

In previous articles, I have guided you on using Viewport Culling and requestAnimationFrame to improve performance when using Konva to render 1,000 elements. Now, we will continue to improve further with Layer Caching and Tiling techniques to handle up to 10,000 elements.

Prerequisites

This article is extended from my previous articles, so please review the previous articles to grasp the necessary information before continuing.


Detail

Update file type.ts

import type {GroupConfig} from 'konva/lib/Group'

export interface ShapeData {
id: string
type: string
x: number
y: number
size: number
color: string
}

export interface ViewRect {
x1: number
y1: number
x2: number
y2: number
}

export interface ShapeItemProps extends GroupConfig {
item: ShapeData
image?: HTMLImageElement
onClick: (id: string) => void
}

export interface TileProps {
items: ShapeData[]
image: HTMLImageElement | undefined
isZoomedOut: boolean
onObjectClick: (id: string) => void
x: number
y: number
tileSize: number
}


Update file utility.ts

import type {ShapeData} from './type'

export const WIDTH = window.innerWidth
export const HEIGHT = window.innerHeight
export const TOTAL_OBJECTS = 10000
export const CANVAS_VIRTUAL_SIZE = 50000
export const TILE_SIZE = 2500
export const ZOOM_THRESHOLD = 0.2
export const IMAGE_URL = 'https://konvajs.org/assets/lion.png'

export const generateData = (): ShapeData[] => {
const types = ['rect', 'square', 'diamond', 'circle', 'triangle', 'image']
return Array.from({length: TOTAL_OBJECTS}, (_, i) => ({
id: `obj-${i}`,
type: types[Math.floor(Math.random() * types.length)],
x: Math.random() * CANVAS_VIRTUAL_SIZE - CANVAS_VIRTUAL_SIZE / 2,
y: Math.random() * CANVAS_VIRTUAL_SIZE - CANVAS_VIRTUAL_SIZE / 2,
size: 40 + Math.random() * 40,
color: `hsl(${Math.random() * 360}, 70%, 60%)`,
}))
}

This code block sets up configuration constants for the canvas (dimensions, a total of 10,000 objects, Tile size) and the generateData function to create mock data for shapes with random positions, colors, and types within the virtual space.


Update file ShapeItem.tsx

import {memo} from 'react'
import {Circle, Image, Rect, RegularPolygon} from 'react-konva'
import type {ShapeItemProps} from './type'

export const ShapeItem = memo(({item, image, onClick}: ShapeItemProps) => {
const {type, x, y, size, color, id} = item
const commonProps = {
x,
y,
fill: color,
id,
onClick: () => onClick(id),
perfectDrawEnabled: false,
shadowForStrokeEnabled: false,
}
switch (type) {
case 'rect':
return <Rect {...commonProps} width={size * 1.2} height={size} />
case 'square':
return <Rect {...commonProps} width={size} height={size} />
case 'circle':
return <Circle {...commonProps} radius={size / 2} />
case 'diamond':
return <RegularPolygon {...commonProps} sides={4} radius={size} />
case 'triangle':
return <RegularPolygon {...commonProps} sides={3} radius={size} />
case 'image':
return <Image {...commonProps} image={image} width={size} height={size} />
default:
return null
}
})

This is the component used to render each specific geometric object. It uses React.memo to avoid unnecessary re-renders and optimizes by disabling expensive rendering features like perfectDrawEnabled, helping the vector drawing process occur faster.


Create file Tile.tsx

import Konva from 'konva'
import React, {useEffect, useRef} from 'react'
import {Group} from 'react-konva'
import {ShapeItem} from './ShapeItem'
import {type TileProps} from './type'

const Tile = React.memo(
({items, image, isZoomedOut, onObjectClick, x, y, tileSize}: TileProps) => {
const groupRef = useRef<Konva.Group>(null)
const idleHandleRef = useRef<any>(null)

useEffect(() => {
const node = groupRef.current
if (!node) return

const performCache = () => {
if (!node) return

node.cache({
x: 0,
y: 0,
width: tileSize,
height: tileSize,
pixelRatio: 0.5,
})

node.getLayer()?.batchDraw()
}

if (isZoomedOut) {
if ('requestIdleCallback' in window) {
idleHandleRef.current = window.requestIdleCallback(
() => performCache(),
{timeout: 2000}
)
} else {
idleHandleRef.current = setTimeout(performCache, 1)
}

return () => {
if (idleHandleRef.current) {
if ('cancelIdleCallback' in window) {
window.cancelIdleCallback(idleHandleRef.current)
} else {
clearTimeout(idleHandleRef.current)
}
}
node.clearCache()
}
} else {
node.clearCache()
}
}, [isZoomedOut, tileSize])

return (
<Group ref={groupRef} x={x} y={y} listening={!isZoomedOut}>
{items.map(item => (
<ShapeItem
key={item.id}
item={{...item, x: item.x - x, y: item.y - y}}
image={image}
onClick={onObjectClick}
/>
))}
</Group>
)
},
(prev, next) => {
return (
prev.isZoomedOut === next.isZoomedOut &&
prev.items === next.items &&
prev.image === next.image
)
}
)

export default Tile

  • This component implements the Layer Caching technique by tile (Tile). When the user zooms out (isZoomedOut), it converts all vector content within the Tile into a single Bitmap image (cache) using requestIdleCallback to avoid causing lag. When zooming in, it clears the cache to return to an interactive vector format.
  • We need to use requestIdleCallback or setTimeout as a form of debounce to avoid calling the cache function too many times, which would affect the main thread.
  • Using the requestIdleCallback function is different from setTimeout in that it can execute caching when the browser is free (before 2s), and when it reaches 2s and the browser is still not free, it will force the cache function to run (unlike setTimeout, which will always run after 2s).
  • Note that the requestIdleCallback function is not yet supported in all browsers; you can check here.


Update file OptimizedCanvas.tsx

import Konva from 'konva'
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {Layer, Stage} from 'react-konva'
import useImage from 'use-image'
import Tile from './Tile'
import {ShapeData, ViewRect} from './type'
import {
generateData,
HEIGHT,
IMAGE_URL,
TILE_SIZE,
WIDTH,
ZOOM_THRESHOLD,
} from './utility'

const OptimizedCanvas: React.FC = () => {
const [data] = useState<ShapeData[]>(generateData)
const [img] = useImage(IMAGE_URL)
const stageRef = useRef<Konva.Stage>(null)
const requestRef = useRef<number>(0)

const [scale, setScale] = useState(1)
const [viewRect, setViewRect] = useState<ViewRect>({
x1: 0,
y1: 0,
x2: WIDTH,
y2: HEIGHT,
})

const tilesMap = useMemo(() => {
const map = new Map<string, ShapeData[]>()
data.forEach(item => {
const tx = Math.floor(item.x / TILE_SIZE)
const ty = Math.floor(item.y / TILE_SIZE)
const key = `${tx}_${ty}`
if (!map.has(key)) map.set(key, [])
map.get(key)?.push(item)
})
return map
}, [data])

const visibleTileKeys = useMemo(() => {
const keys: string[] = []

const startX = Math.floor(viewRect.x1 / TILE_SIZE)
const endX = Math.ceil(viewRect.x2 / TILE_SIZE)
const startY = Math.floor(viewRect.y1 / TILE_SIZE)
const endY = Math.ceil(viewRect.y2 / TILE_SIZE)

for (let x = startX; x <= endX; x++) {
for (let y = startY; y <= endY; y++) {
const key = `${x}_${y}`
if (tilesMap.has(key)) {
keys.push(key)
}
}
}
return keys
}, [viewRect, tilesMap])

const updateViewport = useCallback(() => {
const stage = stageRef.current
if (!stage) return

if (requestRef.current) cancelAnimationFrame(requestRef.current)

requestRef.current = requestAnimationFrame(() => {
const currentScale = stage.scaleX()
const x = stage.x()
const y = stage.y()

setScale(currentScale)
setViewRect({
x1: -x / currentScale,
y1: -y / currentScale,
x2: (-x + WIDTH) / currentScale,
y2: (-y + HEIGHT) / currentScale,
})
})
}, [])

useEffect(() => {
updateViewport()
return () => cancelAnimationFrame(requestRef.current)
}, [updateViewport])

const handleWheel = useCallback(
(e: Konva.KonvaEventObject<WheelEvent>) => {
e.evt.preventDefault()
const stage = stageRef.current
if (!stage) return

const oldScale = stage.scaleX()
const pointer = stage.getPointerPosition()
if (!pointer) return

const scaleBy = 1.15
const newScale =
e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy

stage.scale({x: newScale, y: newScale})
const newPos = {
x: pointer.x - (pointer.x - stage.x()) * (newScale / oldScale),
y: pointer.y - (pointer.y - stage.y()) * (newScale / oldScale),
}
stage.position(newPos)
updateViewport()
},
[updateViewport]
)

return (
<div className="w-full h-screen bg-[#121212] relative overflow-hidden">
<Stage
width={WIDTH}
height={HEIGHT}
draggable
ref={stageRef}
onDragMove={updateViewport}
onWheel={handleWheel}
className="cursor-grab active:cursor-grabbing"
>
<Layer>
{visibleTileKeys.map(key => {
const [tx, ty] = key.split('_').map(Number)
const items = tilesMap.get(key)!

return (
<Tile
key={key}
x={tx * TILE_SIZE}
y={ty * TILE_SIZE}
items={items}
image={img}
tileSize={TILE_SIZE}
isZoomedOut={scale < ZOOM_THRESHOLD}
onObjectClick={id => console.log('Clicked object:', id)}
/>
)
})}
</Layer>
</Stage>

<div className="absolute bottom-4 right-4 p-3 bg-black/60 text-white text-xs font-mono rounded">
<div>Visible Tiles: {visibleTileKeys.length}</div>
<div>
Mode:{' '}
{scale < ZOOM_THRESHOLD
? '📦 Cache (Bitmap)'
: '🎯 Interactive (Vector)'}
</div>
</div>
</div>
)
}

export default OptimizedCanvas

  • This is the main control center, using Spatial Indexing to partition 10,000 elements into Tiles based on coordinates. It performs "Tile-Level Culling" to render only the tiles located within the viewport (Viewport) and uses requestAnimationFrame to smooth the process of updating coordinates when zooming/dragging.
  • Extremely Low Rendering Cost: Instead of tilesMap.forEach (iterating through everything), we use 2 for loops running through the x, y coordinates of the view area. If the screen displays 10 tiles, React renders exactly 10 Tile Components.
  • Low Latency: Separating the visibleTileKeys logic helps React "diffing" extremely quickly because the number of elements in the render array is always stable and small.
  • Event Preservation: When zooming in (isZoomedOut = false), clearCache() is called, reverting items to their original vector form, helping users click accurately on each element.


The result will be as follows: you can still view 10,000 items simultaneously, at which point the elements are in Bitmap format.


Or when zooming in smaller, the elements are in vector format capable of receiving click events.

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Using Kafka with Docker and NodeJS

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Kubernetes Practice Series

NodeJS Practice Series

Sitemap