Network optimization (4x WS message size reduction) for sending a lot of data from server to client (LiveView)
Tags: webdev, elixir, javascript, phoenix
Published: 2024-02-02 18:43:05

In other [article](https://dev.to/azyzz/performance-optimization-when-adding-12000-markers-to-the-map-that-renders-fast-with-elixir-liveview-and-leafletjs-54pf), I discussed about using `Task.async_stream` to concurrently fetch data in chunks and send to the client to render on a map.

When I implemented the aforementioned optimizations in my localhost, results were phenomenal. 12,000+ points would be fetched from my locally running DB and rendered within a second. However, when deployed, it was not nearly as fast. In fact, it was really slow. It was so bad that it scored 0 for performance in Lighthouse report.

I have analyzed every moving part of application to track down the bottleneck. My DB and VM were both deployed in the same country which resulted in little latency and fast query time. JavaScript (JS) execution time was not considered as I opened the page on the same machine (my laptop). When analyzing the timeline of JS execution along with the rendering, I found that it would take up to 1 second for the new chunk of data to 
arrive for JS to execute. 

The network was the bottleneck. And the slow network is known to be an Achilles heel of LiveView’s architecture. 

My configs are:

    Elixir 1.15.7
    OTP 26
    LiveView 0.20.1
    Phoenix 1.7.10
    Phoenix LiveView 0.18.18
    Ecto 3.11
    All running on my MacBook Air M2 8GB Ram


Network optimizations were needed to enhance the performance. Here is what I came up with:

1. Using single push_event as given in docs (duh)

I looked through the WebSocket (WS) messages and found that each message with 2000 projects would weigh 700kb. Looking at the message content, I found my first obvious mistake.

In Part 1, I sent the data using `Enum.reduce`:

```elixir
  def handle_info({:data_received, data}, socket) do
    socket =
      Enum.reduce(data, socket, fn %Project{} = project, socket ->
        socket
        |> push_event("add_project", form_project_map(project))
      end)

    {:noreply, socket}
  end
```

This resulted in WS messages with repetitive event name with this pattern:

`“add_project: [PROJECT], add_project: [PROJECT], add_project: [PROJECT]...”`

So, the function was changed to this: 

```elixir
  def handle_info({:data_received, data}, socket) do
    socket =
      Enum.reduce(data, socket, projects, socket ->
        socket
        |> push_event("add_many_markers_for_project", %{data: projects})
      end)

    {:noreply, socket}
  end
```

Message size was dropped from 750kb to 650kb.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/axj9o8pc99hz7admtain.png)

2. Minimizing data being transferred

I dropped unused fields for the data I query from database. It was relatively easy, by just adding `select: ` to the query.

```elixir
      from i in Investment,
        where: not is_nil(i.latitude) and not is_nil(i.longitude),
        limit: ^count,
        order_by: i.id,  
	select: %{
          project_name: i.project_name,
          source_name: i.source_name,
          type: i.type,
          latitude: i.latitude,
          longitude: i.longitude
        }
```

Next, I shortened the length keys:

```elixir
        select: %{
          n: i.project_name,
          s: i.source_name,
          t: i.type,
          lat: i.latitude,
          lon: i.longitude
        }
```

but wait, why do we need to have shortened keys when we can get rid of the keys altogether?

```elixir
        select: [
          i.project_name,
          i.source_name,
          i.type,
          i.latitude,
          i.longitude
        ]
```

WS message size was reduced from 650kb to around 400kb:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zszytzt6oy66rs5ymrle.png)

All the data and messages transferred between LiveView and Client are all JSON encoded anyways, so basically our data is encoded to a long string that is then decoded on the client-side. What if we could compress the string before sending it?

Good news, I did not even need any dependencies, as `:zlib` comes with Erlang and is easy to use. After I JSON encoded my data, I compressed it using `:zlib.compress`. But the catch here is that `:zlib.compress` produces a binary, so I had to encode it to a string using Base64 encoding.

So, the compression was achieved with 3 lines of code without any dependencies:

```elixir
  def compress(data) do
    data
    |> Jason.encode!()
    |> :zlib.compress()
    |> Base.encode64()
  end
```

This technique allowed to reduce the message size to only around 100kb (4x less!) 

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lzbzi9ct3uf4pclwbkkz.png)


Now, the hardest part - handling the data on the client-side with JS.

There are couple of dependencies that need to be installed:

`pako` - for zlib-decompressing 
`js-base64` - for decoding Base64 encoded string. 

Here is the function that decompresses zlib compressed + base64 encoded string:

```javascript
const decompress_to_json = (base64string) => {
    let base64_decoded = Base64.atob(base64string)
    let charCodes = [];
    for (let i = 0; i < base64_decoded.length; i++) {
        charCodes.push(base64_decoded.charCodeAt(i));
    }
    let inflatedData = pako.inflate(charCodes, { to: 'string' });

    const data = JSON.parse(inflatedData);
    return data;
}
``` 

and my event handler for rendering the points on a map look like this:

```javascript
        this.handleEvent("add_markers_for_project", ({ data }) => {

            const projects = decompress_to_json(data)
            for (let i = 0; i < projects.length; i++) {
                const project = objectifyProject(projects[i]);
                if (!project.latitude || !project.longitude) {
                    return;
                }
                addMarker(project, map, markersLayerGroup);
            }
        });
```

where `objectify()` simply creates object with keys from the array, i.e. from `[_name_, _type_, _lat_, _lon_]` to `{name: _name_, type: _type_, lat: _lat_, lon: _lon_}`

Discussion

Let’s talk about the tradeoffs. JS bundle size has increased because of the additional dependencies. And yes, we are doing a lot more operations on both server side elixir and client side, but is it worth performing more computations in order to optimize for network? Absolutely.

`compress()` function takes around 2.2ms to compress + encode 4000 entries.
`decompress_to_json()` takes around 20ms to decode + decompress 4000 entries. Execution time is negligible compared to the benefits it produces.

As a result, I was able to get my deployed map render in speed comparable to the one on the local host. It is almost instantaneous. Lighthouse score for performance is 93+.