ondelva

← Blog

One SVG Wobble Filter Cost 15 Seconds of Worker CPU. I Rebuilt It in 200 Lines

Rendering a 2400 px print image with resvg on Cloudflare Workers took over 15 seconds of CPU, almost all of it in feTurbulence. Computing only the alpha channel the displacement reads, on a coarse grid, brought it to about a second with 0.15% of pixels different.

Yunseon Hong10 min read

Wobblefolk draws doodle avatars whose outlines wobble a little, as if drawn by a shaky hand. The browser renders them as SVG and never complains. The trouble started with phone cases. The print partner needs a 2400 px PNG, and that PNG is drawn on the server: resvg, a Rust SVG renderer, compiled to WebAssembly and running inside a Cloudflare Worker.

The first print render took 11 to 15 seconds, and the partner’s image upload timed out waiting for it. Rendering the image ahead of time helped until a render used 17.5 seconds of CPU against the 15 second cpu_ms limit I had set, and the Worker died with exceededCpu. Two renders at once went over the 128 MB isolate memory limit instead. I raised the CPU limit to 60 seconds and drew print images one at a time, which stopped the failures. Then I removed the filter from the SVG and rendered again. It finished in 0.2 seconds.

For reference, the Workers limits page gives paid plans a 30 second CPU default per request, configurable up to 5 minutes, and 128 MB per isolate. I read it on 16 October 2026.

The wobble is a noise image and a pixel shift

The wobble is two filter primitives. feTurbulence generates cloudy Perlin noise, and feDisplacementMap moves each source pixel by an amount read from that noise.

<filter id="w" x="-5%" y="-5%" width="110%" height="110%">
  <feTurbulence type="fractalNoise" baseFrequency="0.035" numOctaves="2" seed="229"/>
  <feDisplacementMap in="SourceGraphic" scale="5"/>
</filter>

Without the filter, the outlines are clean vector curves.

A white character in sunglasses on a pink background, drawn without the filter. The outline is smooth.

This is the alpha channel of the noise, shown as gray. Brighter means a larger value, and the same seed always gives the same pattern.

The alpha channel of the turbulence noise, rendered as soft gray blotches.

Shift every pixel by that noise and the outline wobbles by a few pixels.

The same character with the wobble filter applied. The outline is slightly uneven.

Each output pixel takes the source pixel at an offset of scale × (noise − 0.5). When xChannelSelector and yChannelSelector are omitted, both default to A, so the displacement reads the alpha channel of the noise and nothing else. That detail matters below.

resvg computes four noise channels and uses one

The same frame SVG, with three of these filters, took 0.45 seconds without filters and 4.3 seconds with them on my Mac. Ninety percent of the time was the filter. Opening resvg’s turbulence code showed why. It evaluates the noise function for every channel of every pixel:

pixel.r = turb(0);
pixel.g = turb(1);
pixel.b = turb(2);
pixel.a = turb(3);

With the default selectors, three of those four evaluations are thrown away. At 2400 × 2400 that is about 23 million noise evaluations per filter, and 17 million of them are never read.

WebAssembly adds to it. The same file took 1.6 seconds with the native resvg 0.45.1 CLI and 4.3 seconds through @resvg/resvg-wasm 2.6.2, the latest version on npm.

Keeping resvg for everything except the filter

I did not replace resvg. It still draws every shape and every glyph. The new code, raster.js, about 200 lines, only takes the filter away from it.

Only filtered layers get displaced, and only alpha noise is computed Only filtered layers get displaced, and only alpha noise is computed Repeated per layer, stacked in paint order One SVGcharacter, frame background, text Split into layers in paint orderother layers hidden with display=none Unfiltered runbackground, text One filtered elementcharacter, background pattern Draw with resvgsame as before Draw with resvgfilter attribute removed Noise: alpha channel onlypainted area only, 4 px grid Composite as ispremultiplied alpha Shift by whole pixelsnearest sample, linearRGB fix Encode PNGrow by row into deflate, no copy

The steps, in the order the code runs them:

  1. Split the SVG in paint order into layers: runs of unfiltered elements, and single filtered elements. For each layer, hide everything else with display="none", strip the filter attribute, and let resvg draw it.
  2. Compute noise for the alpha channel only, and only inside the area the layer actually painted. Even there it is sampled every 4 pixels and interpolated in between. The noise period is tens of pixels, so the grid is invisible. That is at most 1/64 of the evaluations resvg makes.
  3. Round each offset to a whole pixel and take the nearest source pixel. resvg samples the same way, and that rounding is where the jagged, drawn by hand look of the edge comes from.
  4. Match the color shift too. SVG filters default to the linearRGB color space and go through 8 bits and back, which darkens dark colors slightly. A lookup table with 256 entries reproduces it.
  5. Encode the PNG myself, one row at a time into CompressionStream('deflate'). A 2400 × 2400 RGBA buffer is 23 MB, and holding a second copy for an encoder is how the 128 MB limit gets hit.

The noise function is a direct port of the reference implementation in the SVG 1.1 specification. To get the same pattern, the random number generator has to be consumed in the spec’s order, so the gradients for R, G and B are still generated at setup and discarded. That costs one setup pass, not anything per pixel.

Results

Render time for one frame SVG (three wobble filters) to PNG on my Mac, Node 22, resvg-wasm 2.6.2, median of three runs:

WidthNo filterresvg filterraster.js
1080 px0.11 s0.89 s0.20 s
2400 px0.45 s4.31 s0.88 s

On Workers, the print render went from more than 15 seconds of CPU to about one second. The 60 second limit is still set, and nothing comes close to it now.

Speed was not enough on its own. Avatars are already shared and printed on cases, so a faster renderer that draws them differently would be a regression. I compared the output against resvg pixel by pixel. On the 2400 px frame, 0.15% of pixels differ by more than 16 in any color channel.

Sunglasses highlight magnified 8x. Left: resvg. Middle: raster.js. Right: differing pixels in red.

Zoomed in 8x on the sunglasses highlight, the resvg render (left) and the raster.js render (middle) have the same jagged edge. The red dots on the right are the pixels that differ, all on edges where an offset rounded to the neighboring pixel.

That comparison runs in npm test for four cases: a translucent overlay, user text decorations, the wide Open Graph card and a plain avatar. The test fails if more than 1% of pixels differ.

The fix belongs in resvg

A workaround like this should eventually be deleted, so I opened linebender/resvg#1162 with the repro SVG and the numbers above. The proposal is to pass the set of channels downstream primitives read into the turbulence pass and skip the rest. I offered a PR if the approach is acceptable.

While measuring I found one more thing. The same SVG rendered with the newer native resvg 0.45.1 wobbles far more.

The same SVG rendered twice. Left, resvg-wasm 2.6.2, slightly uneven outline. Right, resvg 0.45.1, heavily distorted outline.

The left side is resvg-wasm 2.6.2, which production uses. raster.js, ported from the spec, matches it. The right side is resvg 0.45.1. There is an open report that resvg applies the displacement scale twice (#1160), and this looks like that bug. I have not checked it against the resvg source.

So a routine renderer upgrade could change avatars people have already bought. Before bumping resvg-wasm, the pixel comparison test has to pass.

Two limits remain. Each layer is drawn at full size, so an avatar with many text decorations calls resvg ten or so times, and if that gets slow I will crop filtered layers to their painted area first. And raster.js uses a regex parser that only understands the SVG Wobblefolk produces, so I am not publishing it as a library. I would rather wait for resvg to make it unnecessary.

Filed under Build log