Cloudinary Blog

Improving Instagram Web With Progressive Image Loading

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.

Needed Changes

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.

How I Intend to Make Improvements

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

Setting Up the App

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:

Copy to clipboard
# Install CRA
npm install -g create-react-app

# Create a New App
create-react-app <app-name>

Persisting Payloads with a Simple API Server

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:

Copy to clipboard
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:

Copy to clipboard
// 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')));

You can start running the app now:

Copy to clipboard
node server.js

# OR

nodemon server.js

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:

Copy to clipboard
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')));

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:

Copy to clipboard
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')));

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.

Getting Ready for the Client (React) App

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

Copy to clipboard
yarn start

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

Copy to clipboard
import React, { Component } from 'react';

import Header from './Header';

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

export default App;

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:

Copy to clipboard
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;

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

Instagram

Showing a Post

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:

Copy to clipboard
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;

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:

Copy to clipboard
//
import Post from './Post';

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

export default App;

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!

Render a List of Posts

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:

Copy to clipboard
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;

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:

Copy to clipboard
<section className="App-main">
  <Posts />
</section>

And there, you have a list of Instagram posts:

Instagram

Connect Real Data

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:

Copy to clipboard
// 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;

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:

Copy to clipboard
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:

Copy to clipboard
const Posts = ({posts}) => (
  <div className="Posts">
    {posts.map(post => <Post {...post} key={post.id}  />)}
  </div>
);

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

Copy to clipboard
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;

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.

Using the Cloudinary Video Player

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:

Copy to clipboard
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:

Copy to clipboard
import cloudinary from 'cloudinary-core';
import 'cloudinary-video-player';

// Video Player CSS
import '../../node_modules/cloudinary-video-player/dist/cld-video-player.min.css';

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:

Copy to clipboard
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;

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

Image and Video Transformation

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:

Copy to clipboard
isVideo = url => url.split('.')[url.split('.').length - 1] === 'mp4';
//...
fetchPublicId = url =>
    url.split('/')[url.split('/').length - 1].split('.')[0];

Now you can call the url method:

Copy to clipboard
<img alt={caption} src={this.cl.url(this.fetchPublicId(image), {
    width: 687,
    height: 687,
    crop: 'pad',
  })} />

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:

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

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:

Copy to clipboard
componentDidMount() {
    if (this.vDom) {
      this.vPlayer = this.cl.videoPlayer(this.vDom, {
          transformation: {
            width: 687,
            height: 687,
            crop: 'pad'
          }
        });
    }
}

Hover to Mute/Unmute

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:

Copy to clipboard
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();
  }
}

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

Copy to clipboard
<div
  className="Post-image-bg"
  onMouseEnter={this.unMutePlayer}
  onMouseLeave={this.mutePlayer}
>
  /* video and image tags*/
</div>

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:

Copy to clipboard
class Post extends Component {
  //....  
  mutePlayer = e => this.vDom && this.vPlayer.mute();
  unMutePlayer = e => this.vDom && this.vPlayer.unmute();
}

Hide Bottom Controls

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:

Copy to clipboard
<video
    ...
    className="cld-video-player vjs-controls-disabled"
    />

Fluid Videos

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:

Copy to clipboard
this.vPlayer.fluid(true);

Scroll Hooks

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:

Copy to clipboard
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);
}

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:

Copy to clipboard
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;
}

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:

Copy to clipboard
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));
}

Scroll to Play

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:

Copy to clipboard
this.vPlayer = this.cl.videoPlayer(this.vDom, {
  // Play video when in viewport
  autoplayMode: 'on-scroll',
  transformation: {
    //.....
  }
});

One Last Word

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

Recent Blog Posts

Our $2B Valuation

By
Blackstone Growth Invests in Cloudinary

When we started our journey in 2012, we were looking to improve our lives as developers by making it easier for us to handle the arduous tasks of handling images and videos in our code. That initial line of developer code has evolved into a full suite of media experience solutions driven by a mission that gradually revealed itself over the course of the past 10 years: help companies unleash the full potential of their media to create the most engaging visual experiences.

Read more
Direct-to-Consumer E-Commerce Requires Compelling Visual Experiences

When brands like you adopt a direct–to-consumer (DTC) e-commerce approach with no involvement of retailers or marketplaces, you gain direct and timely insight into evolving shopping behaviors. Accordingly, you can accommodate shoppers’ preferences by continually adjusting your product offering and interspersing the shopping journey with moments of excitement and intrigue. Opportunities abound for you to cultivate engaging customer relationships.

Read more
Automatically Translating Videos for an International Audience

No matter your business focus—public service, B2B integration, recruitment—multimedia, in particular video, is remarkably effective in communicating with the audience. Before, making video accessible to diverse viewers involved tasks galore, such as eliciting the service of production studios to manually dub, transcribe, and add subtitles. Those operations were costly and slow, especially for globally destined content.

Read more
Cloudinary Helps Minted Manage Its Image-Generation Pipeline at Scale

Shoppers return time and again to Minted’s global online community of independent artists and designers because they know they can count on unique, statement-making products of the highest quality there. Concurrently, the visual imagery on Minted.com must do justice to the designs into which the creators have poured their hearts and souls. For Minted’s VP of Engineering David Lien, “Because we are a premium brand, we need to ensure that every single one of our product images matches the selected configuration exactly. For example, if you pick an 18x24 art print on blue canvas, we will show that exact combination on the hero images in the PDF.”

Read more
Highlights on ImageCon 2021 and a Preview of ImageCon 2022

New year, same trend! Visual media will continue to play a monumental role in driving online conversions. To keep up with visual-experience trends and best practices, Cloudinary holds an annual conference called ImageCon, a one-of-a-kind event that helps attendees create the most engaging visual experiences possible.

Read more