Dev log: Debugging Safari, an ogre with layers

We’re releasing a WebGL feature soon, and let me tell you Safari has lived up to its reputation as the new Internet Explorer.

We’ve had two big issues:

  1. Safari completely crashes the tab with “a problem repeatedly occurred”
  2. Safari WebGL rendering flickers when scrolling the page

Safari completely crashes the tab with “a problem repeatedly occurred”

The most concerning issue was the Safari crash, which only happened on iOS, not the simulator. I don’t have any iOS devices to test with, so I made the decision to get myself an iPad mini. It’s a write off!

Anyway I’m now the proud owner of a cute lil purple iPad and it still crashes so that’s a good thing, now I can work out how to fix it.

After debugging for way too long, I worked out this was a memory usage issue. Even though Safari runs on some of the most powerful mobile hardware around, it has a hard limit on how much RAM a web page can consume. Even when that page is open. So you can’t make full use of the device in Safari.

My problem had several causes:

  1. I was loading multiple WebGL interactives on page load. Deferring these until the user scrolls them into view helped fix the issue.
  2. I was caching WebGL textures to make the interactive feel snappier. In the end I had to remove all the caching optimisations to get under the Safari memory limit, and now each texture is rendered in realtime when it’s needed.
  3. On top of this, I was hitting an issue with too many layers in Safari causing excessive memory usage.

Compositing layers are created when animation happens in your page. Much like old cel animated films, the browser keeps content that isn’t likely to change in separate layers so it can sandwich animated layers in between, without having to draw everything all over again. For instance a parallax effect will have a background layer and a foreground layer, moving at different speeds.

A pink panther cel animation, showing the panther being lifted off the background.
An example of animation layers drawn on old school cel sheets. The pink panther can be added and removed from the scene without redrawing the layers underneath. Via The Art Professor.

Layers in Safari are created by a number of things, including:

  1. 3d transforms – e.g. -webkit-transform: translateZ(0);
  2. the will-change property – (intended to be used as a last resort. It should not be used to anticipate performance problems)
  3. Canvas layers – canvas is designed to be drawn and redrawn at arbitrary times, so the browser keeps it on its own layer.
  4. position:sticky/position:fixed – similar to parallax effects, these are just a part of doing business and we can’t optimise them any further

In our case, we had a number of 3d transforms and unnecessary will-change properties creating extra layers. These were contributing to Safari crashing. Cutting down on these layers stopped our page from crashing.


Safari WebGL rendering flickers when scrolling the page

This one was killing me because I couldn’t reproduce it on the iPad, and I don’t have an iPhone to test on.

This mostly seemed to happen in in-app browsers (like Slack), but this morning a colleague was able to reliably reproduce it in Safari proper, and I was able to reproduce it in the simulator.

It only seemed to happen while scrolling text boxes over the WebGL canvas So my assumption was that something was clearing the canvas without drawing the scene back in. The app is creating a separate offscreen WebGL instance for performing calculations and I assumed it might be some sort of weird race condition.

I tried a number of fixes that didn’t help:

  1. Render every frame, regardless of whether render is needed (i.e. don’t pause rendering when the scene hasn’t changed. Based on this similar bug).
  2. I tried enabling preserveDrawingBuffer in the renderer, because it seemed related. No dice.
  3. Disable anti-aliasing (per this bug, where Mapbox GL and Three.js fight it out in the same WebGL context)
  4. Downgrade to WebGL 1 (instead of 2). I can’t find the original post suggesting this, but it didn’t do anything.

The actual bug in my case was completely unrelated to Three.js.

When the screen resizes, I update the canvas size, the camera aspect, and projection matrix so that the canvas scales to fit its new dimensions:

  resizeToRoot() {
    const rect = this.root.getBoundingClientRect();
    this.renderer.setSize(rect.width, rect.height);
    this.camera.aspect = rect.width / rect.height;
    this.camera.updateProjectionMatrix();
  }

The problem is that this code doesn’t rerender the scene. So after it’s run the frame is left blank until the next requestAnimationFrame runs.

This wasn’t a huge problem, except when the Safari chrome disappears off the page the browser triggers a whole bunch of resizes in rapid succession. These were resizing the scene, and resulted in black frames until the scene rerendered, multiple times per second. And it didn’t happen on the iPad because the chrome never disappears offscreen.

Adding a this.renderer.render() to the resize function was a somewhat inefficient but effective fix.