Remixing a Symfony: Harvie's Journey with Performance

This article serves as the basis for my 2022 Remix Conf talk.

If you're just here for the abstract, check that out here.

Intro

The year was 2020. Marketing teams around the world were giddy as they released their much anticipated and punny “20-20 vision” roadmaps. Meanwhile, a small team rushed around the city doing their first ever home delivery day. Harvie, a small grocery platform based out of Pittsburgh, PA, distributed 37 boxes of freshly procured produce. All was right – well… relatively okay – with the world.

A Harvie food box sitting on a yellow bridge in Pittsburgh

In March of that year, as we all know, everything came crashing down. Long standing restaurants shuttered their windows. Farmers’ markets closed. Pittsburghers turned to Harvie as their main source of food, to avoid venturing into grocery stores. Local producers who were accustomed to setting up tents in parking lots and tennis courts around the city started selling their goods on Harvie. Jubilee Hilltop Ranch, a sustainable, community-focused, family farm located in Osterburg, PA, jumped onto the platform after losing 98% of their business due to lockdowns. Check out their story https://www.youtube.com/watch?v=_Biiu9JmAZY here.

So in the first half of the chaos of 2020, Harvie began to grow, both in terms of producers and members. This had a hugely positive effect on the business and Harvie began to evolve from a small community-supported agriculture (CSA) platform into a large-scale full grocery delivery service.

Growing Pains

Of course, during any large-scale growth in a short amount of time, you’re going to experience some growing pains. In late 2020, I ran a Lighthouse test on a simple content page on Harvie, our farm management platform and Symfony app, and we received a performance score of 31/100. The JavaScript bundle, the API requests, the database lookups - even with minimal UI to render - had a baseline score in the thirties!

In tandem with constructive customer feedback, this helped to catalyze a renewed commitment to performance at Harvie.

Google lighthouse report that shows a performance score of 32, accessibilitys core of 89, best practices score of 87, and an SEO score of 92

The catalog page, which had only a year prior been converted from a Symfony/jQuery combo to part of a new React single page application (SPA), was hit the hardest. The jump to React had addressed several UX concerns and modernized our tech stack, but still fell short. In true Pittsburgh fashion, the underlying page architecture couldn’t handle the weight.

Pittsburgh city bus sinking into the street

It took upward of tens of seconds - on a fast connection! - for members to add and remove items from their cart, and many members dropped off the page entirely. A feature that allowed you to swap products in your cart for other products in the catalog was barely usable. Harvie had gone from offering approximately 40 products to almost 500 in less than a year, and the page did not scale.

Iteration 1: Crisis Mode

Our engineering team got our heads together and asked, “What can we do in the short term to fix some of these issues?” We spent a few hours walking through each step of page load and organized what we saw into a few groups, based on who would be tackling them:

  1. Devops/networking, which includes queuing, proxy negotiation, DNS lookup, and connecting/TCP handshake

  2. Backend/API/database, which includes how long it takes for request to be sent, time waiting for first response to come over the wire (TTFB)

  3. Frontend, which includes how long it takes for content to download (bigger script, slower connection, file size)

chrome performance tool showing that it took 112.35 ms to load the page

You may recognize these groups from the color codes that the Chrome dev tools use to categorize the segments that occur when a page loads in the browser.

From there, we triaged into “quick and easy wins," “involved fixes,” and “part of a future redesign.”

Devops/Networking & Backend/API

Our walkthrough led us to the realization that many images were not being cached, including the hero image, which could take 7 seconds to load and seemed to be blocking first paint. Additionally, we weren’t compressing many of the images on the page. These were easy wins that shaved seconds off of the page load. More involved fixes included updating endpoints and database look-ups to query only the minimum needed.

For some reason, this header image was taking 7 seconds to load and blocking first paint.

Google Lighthouse also proved useful by providing specific areas of improvement. These suggestions led us to gzipping all of our text-based resources. I can’t speak to everything the backend team did, but all of it came about simply because we sat down and stared at this little dev panel together for a few hours.

Frontend

For the frontend, we saw several low-hanging fruits that we knew were relatively small dev efforts that would drastically increase the user’s experience. For one, the bundle size was just too big. There was so much code that a user had to download before getting to interact with the page. We used webpack’s bundle analyzer plugin to identify problems areas. This immediately led to us removing unused i18n packages from moment.js, with the goal of removing moment entirely in the future. I went through each dependency in the package.json and assessed why it was there, and whether it could be removed or upgraded.

One of the biggest problems we had in the frontend was just the sheer number of elements on the page. Before, we had a grid of maybe 20-30 product cards, each with an image and a few buttons. Even without the optimizations, two years ago, the page was functional. We added lazy loading to our now almost 500 images (remember Harvie was never meant to be bigger than a CSA), and surprise! The page stopped crashing on load.

You can see in this screenshot of the performance panel that all of the images on the entire catalog were attempting to load.

I won’t go into detail on every change that I made, but here’s a non-exhaustive list:

  • Rendering issues due to using APIs incorrectly (IntersectionObserver).

  • Better code-splitting (separating our admin codebase from the member-facing code).

  • Components library to cut down on duplicate code.

  • Text compression on all JavaScript files using gzip.

  • Minimize and defer third party scripts where possible (zendesk, hotjar).

  • Minimize expensive API calls where possible (localize to pages that really need it, vs. at a layout level).

  • Remove FOS routes in the frontend (0.4s to load).

All of these changes I mentioned made a substantial difference on page load, especially on mobile. They helped somewhat with the page interactions, such as adding/removing/swapping cart items, but we knew that our current design just would not support the new business model. It was time for a ✨redesign✨.

Iteration 2: The Redesign

The redesigned Harvie Pittsburgh grocery catalog page

By this point, every new feature we were building was getting the official Harvie Performance Review™, based on the lessons we had learned. Our somewhat quick and somewhat easy fixes were out in the wild, and we turned our attention to redesigning the page in a way that was both performant and in alignment with the direction of the business.

  • Instead of loading every single product on the page, we loaded them by category.

  • We removed parts of the product card, such as the description and producer information, and moved these to a new product details page.

  • We generated multiple image sizes for each product (thumbnails, card view, details view) to match how they’d actually be displayed.

  • We removed our “swap” functionality in favor of typical ecommerce add/remove buttons, which removed a whole host of performance issues (previously - in order to swap out a product, you had to load the entire product list again to show its swappable options).

At each step, we stopped and checked performance and made changes as needed. It still wasn’t adding enough value for all of the issues it was causing. When you include a commitment to performance as an essential tenet of design, often the design becomes leaner.

In the clip above, our redesigned page is on the left, and the existing page is on the right. The redesigned page is loading substantially faster than the existing page. But... it wasn’t enough.

Iteration 3: Remixing a Symfony

I wanted to get rid of the spinners! Also, despite the fact that our load time was looking much better, our Lighthouse score was still fairly low.

A little over a year ago, I bought a Remix license as a birthday present to myself. I messed around with several hobby projects, just testing out what it could do. I realized that the colocation of page load data requests with the component could help us break out of our “spinnageddon” as the Remix team calls it.

Although we don’t have anything deployed publicly yet, I was able to get our new grocery pages up and running with Remix and the results speak for themselves. I ran a quick Lighthouse report just to see if we could break out of that 30/100 score. I got this:

One of Remix's selling points is that it minimizes the request waterfall, and that was the case for us. In some parts of the application, we were making multiple API requests on page load while the spinner went wild in the UI.

Here is our network waterfall in the current SPA version of the application, filtered to just show the document, JavaScript, CSS, and any fetches:

This is slightly skewed because the existing application has a few more third party integrations, such as a support widget and analytics, but you can still see that second wave of component useEffect API requests that are made.

Here is that same waterfall with the same conditions, on the Remix version:

In the Remix version, we have a longer document load time, but a minimal number of additional requests. Additionally, since we're only loading what's needed for this one page, the JavaScript that does load is a lot smaller. This is all without taking full advantage of features like nested routing.

Going forward, our plan is to continue to move pages into the new Remix application. Using the framework has become a natural continuation of our performance journey at Harvie and has absolutely been worth the effort.

Conclusion

If we’ve learned anything over the past two years, it’s that performance and building for scale are non-negotiable when we are releasing new features. It has to be baked into everything we do and continually checked for regression. Our product list and customer base is growing at a rapid pace and I feel confident that what we’re doing now will support what Harvie may look like two, five, and seven years into the future.

I want to hear about your experiences! Have you had a similar performance journey? Tweet me @kauffeem and let me know!