Skip to content

Improving Instagram Web With Progressive Image Loading

My work demands that I stay away from my phone and mobile notifications in order to be as productive as possible each day. It’s not unusual to find me at my desk for a total 12 hours a day (I work remotely), with four hours going to browsing the internet.

As I sniff around, Instagram Web is open constantly, with a visit rate of 2.4 percent of my daily hours. I don’t think this is just me, because in the U.S. alone, 17 percent of Instagram traffic comes from desktops.

As an engineer, I have a tendency to try to improve a tool (productivity or fun) I use everyday. For me, it is Instagram. So, I assembled a list of possible Instagram Web improvements I can work on, built a simple Instagram clone, then improved on those points. In this article, I’ll talk more about what I’ve done, and you might even see more room for improvements.

Maybe the Instagram team thought of the points I’m about to discuss, and had their reasons for not making these types of changes. Or, maybe it never occurred to them to do these things. Who knows? Here’s list the features I wish I had every time I access Instagram from my computer (NOT my phone):

  1. Scroll to Play: I keep asking myself why this is not a feature on the web, yet it is on mobile. After a long session using scrolling infinitely on Instagram mobile, I forget that you have to click a video on Instagram Web before it plays. Most time, I just wait thinking my network has slowed down again until I snap back. What exact reason is Instagram Web refusing to play videos? Who knows?

  2. Hover to Mute and Unmute: Yeah, I can afford a hover you know? I am on a desktop, not a mobile phone. A hover event is my well-earned right for buying a large computer. Why not utilize it, Instagram Web? Oh yes, I can click to play and pause — I know that. But what if I want the videos to play without a sound? Hover could do that well.

  3. Progressive Image Loading: We all know that images are optimized and the web app probably uses resolution switching to serve the best image resolution for a browser view port. I live in Africa where slow 3G is a norm, so instead of slashing that grey background at users on a poor network, maybe you can borrow some ideas from Medium and load the images progressively.

Of course I don’t have a lot of time, so I can’t imagine building some of these features from scratch. Oh, unless you’re paying me…then we can talk. What I intend to do is use a third-party service – Cloudinary – that solves most of these problems. Cloudinary is an end-to-end, cloud-based media management solution. It offers media (images and videos) storage, transformation and delivery.

Here is a list of where Cloudinary could help improve the app:

  • Video/image delivery and upload
  • Cropping and padding transformation
  • Width and height transformation
  • Progressive loading
  • Optimization
  • Media storage
  • Video Player with Scroll to Play

Instagram Web was built with React, so, I am going to prove that the limitations are not tool-specific by using React in the examples. Run the following command to create a React app:

# Install CRA
npm install -g create-react-app

# Create a New App
create-react-app <app-name>
Code language: PHP (php)

We need a simple data persistence mechanism to store a list of posts. Each post would have the basic requirements, including nickname, avatar, caption and post media url. We can use file storage on a server to keep the posts as JSON files and read and write from them.

Server Requirements/Dependencies and Configurations

To have the appropriate server running, you would need the following:

  • node: Almighty JS on the server
  • express: HTTP routing framework for Node
  • body-parser: A middleware to parse HTTP body and attach the content to the req object
  • cors: express middleware to enable CORS
  • low: a small local JSON database powered by Lodash
  • uuid: Generates UUID to serve as unique IDs for each posts

Install the server dependencies by running:

npm install express body-parser cors lowdb uuid
Note:

You need to have node installed before running the above command

Next, create a server.js file on the root of the React project. Then import the dependencies and configure express:

// Import Dependencies
const Express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const uuid = require('uuid/v4');

// Create an Express app
const app = Express();

// Configure middleware
app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

// Configure database and use a file adapter
const adapter = new FileSync('db.json');
const db = low(adapter);

// Choose a port
app.set('port', 8070);

/*
*
* [ R O U T E S    H E R E]
*
*/

// Listen to the chosen port
app.listen(app.get('port'), _ => console.log('App at ' + app.get('port')));
Code language: PHP (php)

You can start running the app now:

node server.js

# OR

nodemon server.js
Code language: CSS (css)

HTTP Routes

The React app is going to make HTTP requests to this server and point to a route while doing so. We need to define these routes and implement the behavior when they are visited. The first route creates a single post:

app.set('port', 8070);
//
app.post('/posts', (req, res) => {
  // Assemble data from the requesting client
  // Also assign an id and a creation time
  const post = Object.assign({}, req.body, {
    id: uuid(),
    created_at: new Date()
  });
  
  // Create post using `low`
  db
    .get('posts')
    .push(post)
    .write();
  // Respond with the last post that was created
  const newPost = db
    .get('posts')
    .last()
    .value();
  res.json(newPost);
});
//
app.listen(app.get('port'), _ => console.log('App at ' + app.get('port')));
Code language: JavaScript (javascript)

I am using the express instance to handle a post request using the post instance method. When the request comes in, and the URL matches http://localhost:8070/posts, the callback function passed to this instance method is called. The route just assembles the data received from the client, stores it and sends it back to the client as a confirmation that the persisting process was successful.

Almost the same pattern goes for fetching all the available posts:

app.post('/posts', (req, res) => {});
//
app.get('/posts', (req, res) => {
  // Fetch a post based on the
  // offset and limit
  const posts = db
    .get('posts')
    .orderBy(['created_at'], ['desc'])
    .slice(parseInt(req.query.offset) - 1)
    .take(parseInt(req.query.limit))
    .value();
  
  // Get the total count
  const count = db.get('posts').value().length;
  // Send a response
  res.json({ posts: posts, count: count });
});
// 
app.listen(app.get('port'), _ => console.log('App at ' + app.get('port')));
Code language: JavaScript (javascript)

In addition to returning a queried list of posts, we also get the total number of posts and send it to the client. This would enable the client implement features like pagination or infinite scroll.

Leave the React app running and watching for changes by running the following command:

yarn start

Empty whatever is contained in the ./src/App.js file so we can start on a blank slate. Replace with:

import React, { Component } from 'react';
  
import Header from './Header';

class App extends Component {
  render() {
    return (
      <div className="App">
        <Header />
      </div>
    );
  }
}

export default App;
Code language: JavaScript (javascript)

We trimmed it down to just showing a nav header. Create the header by adding a folder named Header to src and a file in the folder named index.js:

import React from 'react';
import './Header.css';

const Header = () => (
  <nav className="Nav">
    <div className="Nav-menus">
      <div className="Nav-brand">
        <a className="Nav-brand-logo" href="/">
          Instagram
        </a>
      </div>
    </div>
  </nav>
);

export default Header;
Code language: JavaScript (javascript)

Nothing functional, just a fancy header when you add Header.css and image sprite from this commit history:

Instagram

Just like the header, you need a few JSX and styles to show a post prototype on the screen. Add another component Post with a root index and add the following:

import React from 'react';
import './Post.css';

const Post = () => (
  <article className="Post">
    <header>
      <div className="Post-user">
        <div className="Post-user-avatar">
          <img
            class="_rewi8"
            src="https://instagram.flos6-1.fna.fbcdn.net/t51.2885-19/s150x150/14727482_199282753814164_8390284987160592384_a.jpg"
          />
        </div>
        <div className="Post-user-nickname">
          <span>ogrant718</span>
        </div>
      </div>
    </header>
    <div className="Post-image">
      <div className="Post-image-bg">
        <img
          src="https://instagram.flos6-1.fna.fbcdn.net/t51.2885-15/e35/24845932_1757866441186546_5996861590417178624_n.jpg"
          alt=""
        />
      </div>
    </div>
    <div className="Post-caption">
      <strong>ogrant718</strong> Drops at midnight
    </div>
  </article>
);

export default Post;
Code language: JavaScript (javascript)

This commit history has the styles, so you can create Post.css in the same root as index.js and add the styles.

Update the App.js component to include the new post component:

//
import Post from './Post';

class App extends Component {
  render() {
    return (
      <div className="App">
        ...
        <section className="App-main">
          <Post />
        </section>
      </div>
    );
  }
}

export default App;
Code language: JavaScript (javascript)

Save, and once your browser reloads, you should get the following result:

Instagram

We can’t have just a single post, we need a list of them. Let’s get to that!

We need to render a list of posts, and to do that we need another component to contain the Post component and then iterate over an array of data and display a Post component for each item in the array. Create another component Posts:

import React from 'react';
import Post from '../Post';
import './Posts.css';

const Posts = () => (
  <div className="Posts">
    {([1, 2, 3]).map(v => <Post />)}
  </div>
);

export default Posts;
Code language: JavaScript (javascript)

There is no array of data coming from the server yet, so we just iterate through an array of static figures. Now you can replace the Post in App with Posts:

<section className="App-main">
  <Posts />
</section>
Code language: HTML, XML (xml)

And there, you have a list of Instagram posts:

Instagram

Rather than iterating over numbers, why not make a request to your server (which is running) and ask for a list of posts. I already populated the repo with a list of posts. Update the App component to request for them:

// Import axios
import axios from 'axios';

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      posts: [],
      offset: 1,
      limit: 4
    };
    this.baseUrl = 'http://localhost:8070/posts';
  }

  get constructFetchUrl() {
    const { offset, limit } = this.state;
    return `${this.baseUrl}?offset=${offset}&limit=${limit}`;
  }

  componentDidMount() {
    axios
      .get(this.constructFetchUrl)
      .then(({ data }) => (this.setState({posts: data})))
      .catch(err => console.log(err));
  }

  render() {
    return (
      <div className="App">
        <Header />
        <section className="App-main">
          <Posts posts={this.state.posts} />
        </section>
      </div>
    );
  }
}

export default App;
Code language: JavaScript (javascript)

The Posts component now receives a state named props, which is populated after an ajax request. To make this request, you need to install axios, a HTTP library:

yarn add axios

Next, use the componentDidMount lifecycle method to hook-in and make the Ajax request once the React component is read. The axios request returns a promise, which, when resolved, has a payload of posts. You can update the state once the promise is resolved.

You need to receive the posts in the Posts component and replace the array of static numbers with it:

const Posts = ({posts}) => (
  <div className="Posts">
    {posts.map(post => <Post {...post} key={post.id}  />)}
  </div>
);
Code language: JavaScript (javascript)

Then update the static JSX content in the Post component so it shows the actual data from the server:

import React from 'react';

const isVideo = url => url.split('.')[url.split('.').length - 1] === 'mp4';

const Post = ({ user: { nickname, avatar }, post: { image, caption } }) => (
  <article className="Post">
    <header>
      <div className="Post-user">
        <div className="Post-user-avatar">
          <img src={avatar} alt={nickname} />
        </div>
        <div className="Post-user-nickname">
          <span>{nickname}</span>
        </div>
      </div>
    </header>
    <div className="Post-image">
      <div className="Post-image-bg">
        {isVideo(image) ? (
          <video
            className=""
            playsInline
            poster="http://via.placeholder.com/800x800"
            preload="none"
            controls
            src={image}
            type="video/mp4"
          />
        ) : (
          <img
            src={image}
            alt=""
          />
        )}
      </div>
    </div>
    <div className="Post-caption">
      <strong>{nickname}</strong> {caption}
    </div>
  </article>
);

export default Post;
Code language: JavaScript (javascript)

Remember Instagram supports two kinds of posts — images and videos. The isVideo method checks if the URL is a video and then uses the video tag to display it. For everything else, it uses the image tag.

Nothing is wrong with the HTML5 video player you saw above. But there will be a problem soon. For us to transform the videos to our taste, and have 100 percent control over the process, we need to use a third-party library. Cloudinary open sourced a video player recently that handles every video player problem you can think of. You can install the player via npm:

yarn add cloudinary-core cloudinary-video-player
Note:

cloudinary-video-player is the actual library for playing videos while cloudinary-core is the general Cloudinary JavaScript SDK for delivering media contents and transforming them.

Import the libraries to the Post component:

import cloudinary from 'cloudinary-core';
import 'cloudinary-video-player';

// Video Player CSS
import '../../node_modules/cloudinary-video-player/dist/cld-video-player.min.css';
Code language: JavaScript (javascript)

Create an instance of the SDK and the video player and mount the video player instance on the HTML5 video tag we had using ref:

class Post extends Component {
  constructor(props) {
    super(props);
    this.cl = cloudinary.Cloudinary.new({ cloud_name: 'christekh' });
    this.vDom = null;
    this.vPlayer = null;
  }
  componentDidMount() {
    if (this.vDom) {
      this.vPlayer = this.cl.videoPlayer(this.vDom);
      this.vPlayer.source(this.fetchPublicId(this.props.post.image));
    }
  }
  
  isVideo = url => url.split('.')[url.split('.').length - 1] === 'mp4';
    
  render() {
    // ...
      <video
        controls
        loop
        id={this.vId}
        className="cld-video-player"
        ref={vDom => (this.vDom = vDom)}
    //...
  }
}
export default Post;
Code language: JavaScript (javascript)

Notice the component has been updated from functional to class. We need to maintain internal state with instance properties to keep track of things.

When you define width and height dimensions for an image or a video, you are not certain if the user adheres to your instructions and upload that dimension. In fact, a social network app would hardly recommend a dimension. Instead, it would accept whatever dimension the user sends and try as much as possible to make it fit into a design.

Assuming we define a strict width and height of 687px for the image and video, you may end up getting poorly scaled contents. With Cloudinary transformations, we can intelligently adapt the content in a way that it retains quality and we have consistence dimension.

Transforming Images The SDK lets you exposes a url method that takes in an the public ID of a Cloudinary image and returns the full URL. We’ve got no public ID but we have the full URL. The Public ID is the string right before the file extension in the URL. I wrote a method to extract it:

isVideo = url => url.split('.')[url.split('.').length - 1] === 'mp4';
//...
fetchPublicId = url =>
    url.split('/')[url.split('/').length - 1].split('.')[0];
Code language: JavaScript (javascript)

Now you can call the url method:

<img alt={caption} src={this.cl.url(this.fetchPublicId(image), {
    width: 687,
    height: 687,
    crop: 'pad',
  })} />
Code language: HTML, XML (xml)

It takes the publicID and an optional transformation/configuration object. The images now have defined width and height and any image that doesn’t fit is padded.

Progressive Loading and Optimization You will be amazed at how it simple it is to optimize images and load them progressively:

<img alt={caption} src={this.cl.url(this.fetchPublicId(image), {
    width: 687,
    height: 687,
    crop: 'pad',
    flags: 'progressive:steep',
    quality: 'auto'
  })} />
Code language: JavaScript (javascript)

The flags value, progressive:steep loads a poor quality as fast as possible and then progressively updates the image with a higher quality image until it reaches a reasonable stage. Setting quality to auto enables the image to continue optimizing as long as the optimization is lossless (quality doesn’t drop).

Transforming Videos You can transform videos by passing a second argument, which is in the form of a config object to the videoPlayer method:

componentDidMount() {
    if (this.vDom) {
      this.vPlayer = this.cl.videoPlayer(this.vDom, {
          transformation: {
            width: 687,
            height: 687,
            crop: 'pad'
          }
        });
    }
}
Code language: JavaScript (javascript)

Let’s discuss one of my major concerns – hovering to mute and unmute. Now that we have videos setup, this is a perfect time to start tackling this feature. Here is the plan:

  1. Mute videos on load
  2. Unmute on mouse enter
  3. Mute on mouse leave

Let’s start with muting the videos by default. This will still be done in componentDidMount, as soon as we configure the video:

componentDidMount() {
  if (this.vDom) {
    this.vPlayer = this.cl.videoPlayer(this.vDom, {
      //...
    });
    this.vPlayer.source(this.fetchPublicId(this.props.post.image));
    // Mute by default
    this.vPlayer.mute();
  }
}
Code language: JavaScript (javascript)

Then once a user points a mouse to the video or moves the mouse off the video, we want to mute:

<div
  className="Post-image-bg"
  onMouseEnter={this.unMutePlayer}
  onMouseLeave={this.mutePlayer}
>
  /* video and image tags*/
</div>
Code language: HTML, XML (xml)

The onMouseEnter is used to attach the unMutePlayer event to the video container and vice versa. You can create these methods to trigger the mute and unmute:

class Post extends Component {
  //....  
  mutePlayer = e => this.vDom && this.vPlayer.mute();
  unMutePlayer = e => this.vDom && this.vPlayer.unmute();
}  
Code language: JavaScript (javascript)

For an Instagram use case, the bottom control as shown in the image below is not so useful and it is distracting:

Instagram

We can hide it by adding a *vjs-controls-disabled* CSS class to the player:

<video
    ...
    className="cld-video-player vjs-controls-disabled"
    />
Code language: HTML, XML (xml)

Making the video player responsive is as simple as it can get. Call the cld-fluid method on the player instance and pass the method true to get it kicking:

this.vPlayer.fluid(true);

Everyone’s favorite feature about social network is infinite scroll — ability to load more content when you get to the bottom of the content. To implement this feature, you need a scroll hook — something that happens when a scroll occurs. It’s just an event that we can add to the App component:

constructor(props) {
  ...
  this.state = {
    //...
    clientCount: 3,
    serverCount: 0,
    hasMorePosts: false
  };
  this.fetching
  window.addEventListener('scroll', () => {
    if (
      this.bottomVisible &&
      this.fetching === false &&
      this.state.hasMorePosts
    ) {
      this.loadPosts(this.state.offset, this.state.limit);
    }
  });
}

componentDidMount() {
  this.loadPosts(this.state.offset, this.state.limit);
}
Code language: JavaScript (javascript)

When you scroll, the event is fired, and it checks if the bottom of the page is visible before loading posts. It also checks if the fetching instance variable is false (it’s set to true when a request is going on). Finally, it checks if there are more posts that the server needs to send.

The bottomVisible is a getter that returns a boolean if we have scrolled to the bottom:

get bottomVisible() {
  const scrollY = window.scrollY;
  const visible = document.documentElement.clientHeight;
  const pageHeight = document.documentElement.scrollHeight;
  const bottomOfPage = visible + scrollY >= pageHeight;
  return bottomOfPage || pageHeight < visible;
}
Code language: JavaScript (javascript)

I moved loadPosts into a method so we can reuse it both in componentDidMount and when we scroll to the bottom. Here is the updated version:

loadPosts(offset, limit) {
  this.fetching = true;
  axios
    .get(this.constructFetchUrl(offset, limit))
    .then(({ data: { posts, count } }) => {
  this.setState(
        {
          posts: [...this.state.posts, ...posts],
          serverCount: count,
          hasMorePosts: this.state.clientCount < count ? true : false,
          offset: this.state.offset + this.state.limit,
          clientCount: this.state.clientCount + 3
        },
        () => {
          this.fetching = false;
        }
      );
    })
    .catch(err => console.log(err));
}
Code language: JavaScript (javascript)

Another limitation of the Instagram Web is that unlike Instagram mobile, videos don’t play when they are scrolled into the viewport. Cloudinary solves this with a simple configuration:

this.vPlayer = this.cl.videoPlayer(this.vDom, {
  // Play video when in viewport
  autoplayMode: 'on-scroll',
  transformation: {
    //.....
  }
});
Code language: JavaScript (javascript)

I made this prototype clone in a day — in fact, while waiting for a delayed flight. However, such a quick turnaround would not have been the case if I had chosen the route of implementing the whole transformation and video features myself. Cloudinary does that for you, so you can focus more on writing business-specific code and not utility logics. Let me know your thoughts in the comments section below.

CODE

Back to top

Featured Post