Discover everything we launched at Webflow Conf 2024
Read post
Blog
Powering Webflow Apps: How we built Designer APIs - Part 2

Powering Webflow Apps: How we built Designer APIs - Part 2

Learn about the driving force behind our Designer Extension Apps.

Powering Webflow Apps: How we built Designer APIs - Part 2

Learn about the driving force behind our Designer Extension Apps.

We're hiring!

We're looking for product and engineering talent to join us on our mission to bring development superpowers to everyone.

Explore open roles
Explore open roles
Written by
James Mosier
James Mosier
Staff Software Engineer
James Mosier
James Mosier

Webflow’s mission is to bring development superpowers to everyone.

In August 2023 we shipped our first ever Designer APIs that enabled developers to interact with the Webflow Designer. Developers immediately started creating powerful Apps to enable Webflow users to build faster and more feature-rich sites. With the launch of these APIs, the feature requests started rolling in to build new functionality and we realized that in order to harness the full power of the Designer we would need to take a different approach to how we architect our APIs. With this re-architecture (V2) came some performance challenges that were resolved with a multi-faceted approach to the problem.

Designer API V1 vs V2

As discussed in part 1, the current Designer APIs (V2) are designed to enable immediate communication with our internal application logic for each API request, a capability that was not available with our V1 APIs. Below is an example demonstrating how you would write the same code for both V1 and V2:

// Setting a custom attribute
// V1
const el = await webflow.getSelectedElement();
el.setCustomAttribute('data-id', '123');
await el.save()

// V2
const el = await webflow.getSelectedElement();
await el.setCustomAttribute('data-id', '123');

We changed the approach to how we persisted the data for all APIs, including elements, pages, and styles. When executing V1 APIs, the updates were stored in memory and were not persisted to the Webflow Designer until the save() API was called. This made working with objects in bulk more performant, but this came with quite a few downsides:

  • We were not able to immediately validate the input of the APIs. This meant failures were not caught early enough and we had to restrict certain functionality to avoid security concerns.
  • Leveraging the full power of the Designer’s internal logic was not possible. For example, in V1 you were not able to create any type of element as you can in V2 by using the Element Presets.

Ultimately we were limited by the APIs we’d be able to build using the V1 architecture. 

Initial optimizations

As developers transitioned from V1 to V2, we began receiving reports of performance issues when performing bulk operations, such as inserting dozens of elements onto the canvas or updating all page metadata values simultaneously. The V2 APIs changed the way requests were handled; instead of batching requests and sending them once per set of operations like in V1, the App was now sending a request for each individual API call. As a result, we needed to optimize the handling of bulk operations in V2 to match the performance of V1.

We started our performance investigation by targeting APIs that had both high usage as well as a high performance cost, especially for larger sites. These APIs were our element insertion APIs (append, prepend, before, & after). 

We started by profiling each part of the API lifecycle using the performance.now API. We dissected each piece of logic, and we discovered that the logic was unnecessarily iterating over all elements on the page to locate the target element, and kept iterating even after the desired element had already been found. The reason for this was a reused function that was never optimized for this specific use case. 

The code update looked like this:

// Before
const addElementAtAnchor = (page, newElement, placementPosition) => {
  const paths = findPaths(page, el => el.id === newElement.id);
  const path = paths[0];
  return addElementAtPosition[placementPosition](page, path, newElement);
};

// After
const addElementAtAnchor = (page, newElement, placementPosition) => {
  const path = findPath(page, el => el.id === newElement.id);
  return addElementAtPosition[placementPosition](page, path, newElement);
};

We introduced a small but impactful change by creating a new function, findPath, which stops iterating after finding the first instance of an element on the page. This optimization reduced the number of iterations, resulting in a 50% decrease in API execution time (even more for larger sites). While this was a significant improvement, we noticed that the API was still slower than ideal when invoked multiple times in quick succession. Each API call now averaged around 20ms, but inserting dozens of elements at once was still slower compared to our V1 API.

Further profiling

After the initial optimization, we took a step back to consider the entire API lifecycle. With V2 APIs, we now communicate with the Designer each time an API is called. This inherently has three main sources of overhead.

  • postMessage communication Designer APIs use the window.postMessage JavaScript API to send and receive data between the Designer & App.
  • Application state processing Each API also updates our internal Flux state (our internal version of Redux) so the data can be persisted to the database.
  • Logic execution APIs have their own set of logic they use to process the payload.

In order to get a baseline of this overhead, we measured each source to determine how long they took to process. We used the performance.now and console.time APIs to get an average duration for each part of our API logic. 

Measuring postMessage communication

To determine how long the average API takes to send and receive a response, we measured the postMessage request and response time between the App and Designer.

// We executed this in the Designer window.
// This script listens for the postMessage request from the App.
window.addEventListener('message', e => {
  if (e.data === 'ping') e.source.postMessage('pong', '*');
});

// These were executed in the App window.
// This script listens for the response from the Designer
window.addEventListener('message', e => {
  if (e.data === 'pong') console.timeEnd('round-trip');
});
// This script sends the request, similar to how our APIs work
(() => {
  console.time('round-trip');
  window.parent.postMessage('ping', '*');
})();

Throughout this process, we observed that the duration of postMessage varied based on factors such as the browser’s current memory usage and the state of the JavaScript event loop. On average, this stabilized around 4ms per request when tested on the latest versions of Chrome and Firefox. While this didn’t raise any immediate concerns, we will continue monitoring Designer memory usage to ensure this average is maintained.

Flux state processing

We use a Flux architecture for storing state across the entire Designer. This follows similar patterns to Redux, where application code can dispatch actions then a reducer processes that action and returns the updated state. Designer APIs perform their own reducer logic and then we dispatch an action to commit the state. Therefore we wanted to understand what the baseline duration was from an action being dispatched to the reducers to be done processing.

const t0 = performance.now();
dispatch({type: 'API_EXECUTION'});
fluxState.onChange((action) => {
  if (action === 'API_EXECUTION') {
    const t1 = performance.now();
    console.log(`Flux processing time: ${t1 - t0}ms`);
  }
});

By listening for the time it takes for an action to be processed, we found that this averaged at about 5ms. This is an area we plan to optimize further in the future, as this would benefit not only our Designer APIs but the Webflow Designer as a whole. Although for this immediate initiative, we decided to tackle other areas of opportunity first.

Logic Execution

Finally, each API performs its own set of specialized logic. We started looking at each API to determine if there were any optimizations we could implement. But taking a step back, we realized that with the above findings, we’d always have some overhead for every API. The overhead is a side effect of our postMessage architecture which enables us to have a constant feedback loop between an App and the Designer. Therefore we were willing to accept the overhead and determine another path forward for getting closer to parity with V1’s performance.

Element Builder API

After profiling each part of the API lifecycle, we found that we had a baseline duration of ~10ms for any given API to execute (postMessage execution + Flux processing). To eliminate this compounding duration when APIs are executed serially, we created the element builder API. This API takes its inspiration from V1 where we build an element tree in memory and only sync it once the element is appended to an existing element on the canvas. 

// Get Selected Element
const selectedElement = await webflow.getSelectedElement()
// Build an element
const rootElement = webflow.elementBuilder(webflow.elementPresets.DOM)
// Append a child element
const childElement = rootElement.append(webflow.elementPresets.DOM)
// Add root element to the designer by appending to selected Element
await selectedElement.append(rootElement)

We also added the ability to set attributes and styles with this new element builder API, helping to dramatically improve API execution time. With this change, we saw that using the element builder API was just as fast as its V1 predecessor. 

page.setMetadata API

In addition to our new element builder API, we also released an API for inserting multiple page metadata values at once: page.setMetadata. This allows developers to make bulk updates to a page all in one API call. 

const page = await webflow.getCurrentPage();
await page.setMetadata({
  name: 'About,
  description: 'All about myself',
  isDraft: true,
});

Promise.all

In addition to the above API, we recommended that developers use the Promise.all API when performing bulk API operations. This helps to run all Designer Extension API requests concurrently, dramatically reducing the time it takes to execute these APIs. 

As an example, if the below API takes 30ms to complete all requests, then using Promise.all cuts that in half. 

const el = await webflow.getSelectedElement();
await el.setCustomAttribute('data-id', '123');
await el.setCustomAttribute('foo', 'bar');

Using Promise.all

const el = await webflow.getSelectedElement();
await Promise.all([
  el.setCustomAttribute('data-id', '123')
  el.setCustomAttribute('foo', 'bar')
]);

This pattern empowers developers to efficiently perform bulk operations while preserving the integrity of our V2 architecture. By leveraging built-in JavaScript functionality, we ensure that our APIs remain concise, secure, and robust, all while supporting bulk use cases.

Next steps

As developers continue to build new Apps, we are keeping an eye on the APIs being leveraged and how we can help make them even faster. In the coming months, we plan to ship APIs for working with page metadata in bulk as well as additional APIs for working with custom attributes. We also are working on automated ways to track performance across all APIs so that we can be proactive in our performance journey. Performance work is never done, but by having a good foundation and direction for our APIs, we believe we can give both developers and users the best possible experience. 

No items found.
Last Updated
September 25, 2024
Category