Adaptive Loading Based on the User's Network

· 6 min read

During the recent Chrome Dev Summit, Addy Osmani announced react-adaptive-hooks, a way to deliver the best possible experience to users based on their network and hardware limits.

I wanted to see what it will take to achieve something similar with Ember.js. react-adaptive-hooks consists of several utilities that provide information about the CPU cores, hardware concurrency and memory among others. In this blog, let's just consider the user's network.

The effectiveType property on the NetworkInformation interface is an experimental technology that currently works only on Chrome, Opera, Android Webview, and, Chrome, Opera and Firefox on Android. Its value is a string containing one of slow-2g, 2g, 3g, or 4g. Based on this value, it's possible to dynamically change the assets that are fetched. Let's see how this can be used to load different versions of the same image.

First, let's write an Ember component that displays an img. This would be the template.

  <img alt="Adaptive Loading" src={{this.src}}>

In the constructor of this component's backing class definition, let's check if the effectiveType feature is available in the current browser. Let's have different srcs based on this.

import Component from '@glimmer/component';

export default class AdaptiveImageComponent extends Component {
  constructor() {
    super(...arguments);

    if (
      'connection' in navigator &&
      'effectiveType' in navigator.connection
    ) {
      let syntaxHighlighting;
      let breaks;
      let withoutThese = '¯\_(ツ)_/¯';

      // concentrate here
      this.canAdapt = true;
    }
  }

  get src() {
    if (this.canAdapt) {
      return 'this-image.png';
    }

    return 'that-image.png';
  }
}

Let's now try and make use of the effectiveType property. Let's track changes to this property as well.

import Component from '@glimmer/component';

export default class AdaptiveImageComponent extends Component {
  constructor() {
    super(...arguments);

    if (
      'connection' in navigator &&
      'effectiveType' in navigator.connection
    ) {
      let syntaxHighlighting;
      let breaks;
      let withoutThese = '¯\_(ツ)_/¯';

      // concentrate here
      // set current network type
      this.effectiveType = navigator.connection.effectiveType;

      this.canAdapt = true;
    }
  }

  @tracked effectiveType = null;

  get src() {
    if (this.canAdapt) {
      let { effectiveType } = this;
      // return the highest possible resolution on 4g
      if (effectiveType === '4g') {
        return 'large.jpg';
      }

      // return medium resolution on 3g
      if (effectiveType === '3g') {
        return 'medium.jpg';
      }

      // return the smallest resolution otherwise
      return 'small.jpg';
    }

    // Return the highest resolution when NetworkInformation is unavailable.
    return 'large.jpg';
  }
}

Now to respond to changes in the network speed, we can add an event listener.

import Component from '@glimmer/component';

export default class AdaptiveImageComponent extends Component {
  constructor() {
    super(...arguments);

    if (
      'connection' in navigator &&
      'effectiveType' in navigator.connection
    ) {
      // set current network type
      this.effectiveType = navigator.connection.effectiveType;

      // watch for network changes
      this.handleEffectiveTypeChanged = this.onEffectiveTypeChanged.bind(this);
      navigator.connection.addEventListener(
        'change',
        this.handleEffectiveTypeChanged
      );

      this.canAdapt = true;
    }
  }

  @tracked effectiveType = null;

  // update effectiveType whenever the event listener is triggered
  onEffectiveTypeChanged(event) {
    this.effectiveType = event.target.effectiveType;
  }

  get src() {
    if (this.canAdapt) {
      let { effectiveType } = this;
      // return the highest possible resolution on 4g
      if (effectiveType === '4g') {
        return 'large.jpg';
      }

      // return medium resolution on 3g
      if (effectiveType === '3g') {
        return 'medium.jpg';
      }

      // return the smallest resolution otherwise
      return 'small.jpg';
    }

    // Return the highest resolution when NetworkInformation is unavailable.
    return 'large.jpg';
  }

  // remove event listener
  willDestroy() {
    if (this.canAdapt) {
      navigator.connection.removeEventListener(
        'change',
        this.handleEffectiveTypeChanged
      );
    }

    super.willDestroy(...arguments);
  }
}

We can also go ahead and use the saveData property(also experimental) that returns true if the user has set a reduced data usage option on the user agent.

import Component from '@glimmer/component';

export default class AdaptiveImageComponent extends Component {
  constructor() {
    super(...arguments);

    if (
      'connection' in navigator &&
      'effectiveType' in navigator.connection
    ) {
      // set current network type
      this.effectiveType = navigator.connection.effectiveType;

      // watch for network changes
      this.handleEffectiveTypeChanged = this.onEffectiveTypeChanged.bind(this);
      navigator.connection.addEventListener(
        'change',
        this.handleEffectiveTypeChanged
      );

      this.canAdapt = true;
    }
  }

  @tracked effectiveType = null;

  // update effectiveType whenever the event listener is triggered
  onEffectiveTypeChanged(event) {
    this.effectiveType = event.target.effectiveType;
  }

  get src() {
    if (this.canAdapt) {
      // return lowres when data saver is on
       if (NetworkInformation.saveData) {
         return 'small.jpg';
      }

      let { effectiveType } = this;
      // return the highest possible resolution on 4g
      if (effectiveType === '4g') {
        return 'large.jpg';
      }

      // return medium resolution on 3g
      if (effectiveType === '3g') {
        return 'medium.jpg';
      }

      // return the smallest resolution otherwise
      return 'small.jpg';
    }

    // Return the highest resolution when NetworkInformation is unavailable.
    return 'large.jpg';
  }

  // remove event listener
  willDestroy() {
    if (this.canAdapt) {
      navigator.connection.removeEventListener(
        'change',
        this.handleEffectiveTypeChanged
      );
    }

    super.willDestroy(...arguments);
  }
}

Warning

When I was playing around with this, I realised that there's a huge gotcha with this technique. Since there is a listener for changes on navigator.connection, during network fluctuations, it's easy to end up re-fetching assets that were already fetched. One example of this would be how the data speeds vary a lot while travelling. If there is an image that was already fetched but is no longer in viewport(but still present in the DOM), a network change event will end up changing this image's src as well. As a result, this image's new src will be fetched even though it's not even needed anymore. If your use-case does not demand listening to this event, you can skip adding the event listener and avoid this issue.

I have filed this as an issue on react-adaptive-hook's repo and there are a few patterns that are being worked on by the devs at Google that you can check out.


RSS FeedTwitterGitHubEmailToggle Dark Mode OnToggle Dark Mode OffLink