November 18

style.setProperty vs setAttribute

Recently, I encountered an interesting question: which is faster, element.style.setProperty(property, value) or element.setAttribute('style', 'property: value')? At first glance, the answer seems obvious. Logic suggests that setProperty should store the value directly in the CSSOM, while setAttribute first sets the style attribute, which is then parsed into the CSSOM. Therefore, setProperty should be faster. But is it really that straightforward? Let's delve into it.

To begin, let's refresh some foundational concepts. We know that styles are described using the CSS language. After receiving a string representation of styles in CSS, the browser parses it and constructs a CSSOM object. The interface of this object is defined by the specification https://www.w3.org/TR/cssom-1. It adheres to the principles of cascading and inheritance outlined in https://www.w3.org/TR/css-cascade-4.

From the specifications mentioned above, we know that the fundamental unit of CSS is a property. Each property is assigned a value that is specific to that property. If a value is not explicitly specified, it is inherited from a parent style, or, if there is no parent, it will be set to the initial value.

The set of properties for an element is organized into CSSRule. There are different types of rules, with the most common type being the CSSStyleRule, which defines the properties of an element. This type of rule begins with a valid selector followed by curly braces containing a set of properties and values, formatted as <selector>: { ... }. Other types of rules exist as well, such as the CSSFontFaceRule, which describes the parameters for an imported font using @font-face { ... }, the CSSMediaRule, represented as @media { ... }, and others. You can find a complete list in the specification at https://www.w3.org/TR/cssom-1/#css-rules.

These rules are compiled into what is known as a CSSStyleSheet, which serves as an abstract representation of the <style> tag. In turn, style sheets are aggregated into a collection and associated with the document.

All of these levels of abstraction constitute the CSS Object Model (CSSOM). While the specification primarily describes the syntax of the model, in practice, it also includes the direct implementation of the CSS API used by browsers. Functions such as computed style, color encoding methods, mathematical operations, and many others are also part of CSSOM.

Setting Properties in Blink

Having covered the theory, let's take a closer look under the hood of the browser, specifically at the Blink rendering engine used by browsers like Chromium, Opera, WebView, and others. Blink is part of the Chromium repository and is not supplied independently. Therefore, we will be conducting our experiments using Chromium (version 132.0.6812.1 as of the time this article was written).

style.setProperty

Let's begin with the method style.setProperty.

element.style.setProperty("background-color", "red");

To understand what happens under the hood of Blink, it's essential to take a closer look at the inner workings of Blink itself.

/third_party/blink/renderer/core/css/abstract_property_set_css_style_declaration.cc#130

void AbstractPropertySetCSSStyleDeclaration::setProperty(
    const ExecutionContext* execution_context,
    const String& property_name,
    const String& value,
    const String& priority,
    ExceptionState& exception_state) {
  CSSPropertyID property_id =
      UnresolvedCSSPropertyID(execution_context, property_name);
  if (!IsValidCSSPropertyID(property_id) || !IsPropertyValid(property_id)) {
    return;
  }
  
  bool important = EqualIgnoringASCIICase(priority, "important");
  if (!important && !priority.empty()) {
    return;
  }
  
  const SecureContextMode mode = execution_context
                                     ? execution_context->GetSecureContextMode()
                                     : SecureContextMode::kInsecureContext;
  SetPropertyInternal(property_id, property_name, value, important, mode,
                      exception_state);
}

First, a basic validity check is performed on the property that has been passed in. Then, the style's priority is evaluated, and an internal method SetPropertyInternal is invoked.

/third_party/blink/renderer/core/css/abstract_property_set_css_style_declaration.cc#236

void AbstractPropertySetCSSStyleDeclaration::SetPropertyInternal(
    CSSPropertyID unresolved_property,
    const String& custom_property_name,
    StringView value,
    bool important,
    SecureContextMode secure_context_mode,
    ExceptionState&) {
  StyleAttributeMutationScope mutation_scope(this);
  WillMutate();
  
  MutableCSSPropertyValueSet::SetResult result;
  if (unresolved_property == CSSPropertyID::kVariable) {
    AtomicString atomic_name(custom_property_name);
    
    bool is_animation_tainted = IsKeyframeStyle();
    result = PropertySet().ParseAndSetCustomProperty(
        atomic_name, value, important, secure_context_mode, ContextStyleSheet(),
        is_animation_tainted);
  } else {
    result = PropertySet().ParseAndSetProperty(unresolved_property, value,
                                               important, secure_context_mode,
                                               ContextStyleSheet());
  }
  
  if (result == MutableCSSPropertyValueSet::kParseError ||
      result == MutableCSSPropertyValueSet::kUnchanged) {
    DidMutate(kNoChanges);
    return;
  }
  
  CSSPropertyID property_id = ResolveCSSPropertyID(unresolved_property);
  
  if (result == MutableCSSPropertyValueSet::kModifiedExisting &&
      CSSProperty::Get(property_id).SupportsIncrementalStyle()) {
    DidMutate(kIndependentPropertyChanged);
  } else {
    DidMutate(kPropertyChanged);
  }
  
  mutation_scope.EnqueueMutationRecord();
}

Within this internal method, a mutation process is initiated, the string value is parsed, and this value is assigned to the specified property. Following this, the changes are reflected in the CSSOM. Overall, the process is straightforward.

setAttribute

Now, Let's Examine setAttribute.

element.setAttribute("style", "background-color: red");

This operation goes beyond the CSSOM and directly affects elements and their attributes, resulting in modifications to the DOM. Within the DOM, there is a class called Attr that describes a single attribute of an element. There are several ways to set the value of an attribute.

/third_party/blink/renderer/core/dom/attr.cc#73

void Attr::setValue(const AtomicString& value,
                    ExceptionState& exception_state) {
  // Element::setAttribute will remove the attribute if value is null.
  DCHECK(!value.IsNull());
  if (element_) {
    element_->SetAttributeWithValidation(GetQualifiedName(), value,
                                         exception_state);
  } else {
    standalone_value_or_attached_local_name_ = value;
  }
}

This method allows us to assign a value to an existing attribute. In other words, we can do something like this.

element.style = "background-color: red";

This will lead to the invocation of the method SetAttributeWithValidation, which we will discuss shortly.

However, in our original case, we are not directly accessing the style attribute; instead, we are calling the setAttributemethod from the Element class.

/third_party/blink/renderer/core/dom/element.h#282

void setAttribute(const QualifiedName& name, const AtomicString& value) {
  SetAttributeWithoutValidation(name, value);
}

This method, in turn, calls another method: SetAttributeWithoutValidation.

So, what are the methods SetAttributeWithoutValidation and SetAttributeWithValidation?

/third_party/blink/renderer/core/dom/element.cc#10314

void Element::SetAttributeWithoutValidation(const QualifiedName& name,
                                            const AtomicString& value) {
  SynchronizeAttribute(name);
  SetAttributeInternal(FindAttributeIndex(name), name, value,
                       AttributeModificationReason::kDirectly);
}

void Element::SetAttributeWithValidation(const QualifiedName& name,
                                         const AtomicString& value,
                                         ExceptionState& exception_state) {
  SynchronizeAttribute(name);
  
  AtomicString trusted_value(TrustedTypesCheckFor(
      ExpectedTrustedTypeForAttribute(name), value, GetExecutionContext(),
      "Element", "setAttribute", exception_state));
  if (exception_state.HadException()) {
    return;
  }
  
  SetAttributeInternal(FindAttributeIndex(name), name, trusted_value,
                       AttributeModificationReason::kDirectly);
}

As we can see, both methods essentially perform the same function. They first synchronize the current value of the attribute to account for any pending mutations. After that, they proceed to set the new value. The only difference is that in the second case, the new value is validated against the specified attribute before it is set. In other words, the following is not allowed.

element.style = "invalid-prop: red"
// <div style></div>

Attempting to assign an invalid style to the style attribute will result in the attribute's value being removed.

However, there is nothing stopping us from doing this instead.

element.setAttribute("style", "invalid-prop: red")
// <div style="invalid-prop: red"></div>

In this case, the style will be set regardless of what we specified as the value.

This seems quite strange. Why not validate attribute values all the time? The answer is quite simple. The setAttribute method acts as a sort of backdoor for attributes. It is designed to operate not only with built-in attributes but also with arbitrary ones, such as data-*.

element.setAttribute("data-ship-id", "324");
element.setAttribute("data-weapons", "laserI laserII");
element.setAttribute("data-shields", "72%");
element.setAttribute("data-x", "414354");
element.setAttribute("data-y", "85160");
element.setAttribute("data-z", "31940");
element.setAttribute("onclick", "spaceships[this.dataset.shipId].blasted()");

The setAttribute method operates at the DOM element level, and its primary function is to assign a value to an attribute without any validations. A detailed algorithm is described in the DOM standard.

Which is Faster?

Based on the above information, we can speculate that style.setProperty should be faster since, unlike setAttribute, the engine doesn’t need to search for a reference to the attribute object in the attribute table and can directly proceed to set the value. On the other hand, the overhead of validating the value itself could be significant.

Test 1: One Property

To determine which method is actually faster, we will conduct an experiment. For this purpose, we will need an HTML page with a test element and a couple of buttons.

<div id="test-element"></div>
<button onclick="handleSetProperty()">style.setProperty</button>
<button onclick="handleSetAttribute()">setAttribute</button>

Additionally, a simple JavaScript script will be required.

const N = 100;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function createElement() {
  const el = document.getElementById("test-element");
  
  const newEl = document.createElement("div");
  newEl.setAttribute("id", "test-element");
  newEl.setAttribute("width", 100);
  newEl.setAttribute("height", 100);
  
  el.replaceWith(newEl);
  
  return newEl;
}

async function handleSetProperty() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.style.setProperty("background-color", "red");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

async function handleSetAttribute() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.setAttribute("style", "background-color: red");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

This script, upon clicking one of the buttons, will initiate a loop of 100 iterations that sets the value of the style for a test element. To ensure the integrity of the experiment, we will create a new test element during each iteration and insert pauses after creating the element and after setting the value. This approach aims to eliminate browser optimizations and guarantee the rendering of the element after each iteration. We will take measurements separately, following a hard refresh of the page.

After collecting the measurements from the 100 iterations, we will calculate the simple average to obtain the final result.

avgSetProperty     0.07400000274181366
avgSetAttribute    0.08999999761581420

In this experiment, style.setProperty clearly takes the lead. However, we only attempted to set a single CSS property.

Test 2: Two Properties

For objectivity, we will repeat the experiment with two properties simultaneously. To facilitate this, we will make slight adjustments to the test functions.

async function handleSetProperty() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.style.setProperty("background-color", "red");
    el.style.setProperty("border", "1px solid blue");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

async function handleSetAttribute() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.setAttribute("style", "background-color: red; border: 1px solid blue;");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

Upon running the loops again, we obtained the following result.

avgSetProperty     0.10900000214576722
avgSetAttribute    0.10399999618530273

The situation has changed. The average speed of both methods has approximately equalized. The setAttribute method is actually a little faster; however, this difference can be attributed to potential measurement inaccuracies. The reason for this is not even validation, as it is still insignificant at this point. In this case, another process comes into play: the mutation of the CSSStyleDeclaration object. This interface describes the structure of a set of styles, such as the body of the <style> tag. A similar object also stores the style for an individual element. I previously provided a code snippet for the method that sets a property value in CSSStyleDeclaration. Let's take another look at it.

/third_party/blink/renderer/core/css/abstract_property_set_css_style_declaration.cc#236

void AbstractPropertySetCSSStyleDeclaration::SetPropertyInternal(
    CSSPropertyID unresolved_property,
    const String& custom_property_name,
    StringView value,
    bool important,
    SecureContextMode secure_context_mode,
    ExceptionState&) {
  StyleAttributeMutationScope mutation_scope(this);
  WillMutate();

  MutableCSSPropertyValueSet::SetResult result;
  if (unresolved_property == CSSPropertyID::kVariable) {
    AtomicString atomic_name(custom_property_name);

    bool is_animation_tainted = IsKeyframeStyle();
    result = PropertySet().ParseAndSetCustomProperty(
        atomic_name, value, important, secure_context_mode, ContextStyleSheet(),
        is_animation_tainted);
  } else {
    result = PropertySet().ParseAndSetProperty(unresolved_property, value,
                                               important, secure_context_mode,
                                               ContextStyleSheet());
  }

  if (result == MutableCSSPropertyValueSet::kParseError ||
      result == MutableCSSPropertyValueSet::kUnchanged) {
    DidMutate(kNoChanges);
    return;
  }

  CSSPropertyID property_id = ResolveCSSPropertyID(unresolved_property);

  if (result == MutableCSSPropertyValueSet::kModifiedExisting &&
      CSSProperty::Get(property_id).SupportsIncrementalStyle()) {
    DidMutate(kIndependentPropertyChanged);
  } else {
    DidMutate(kPropertyChanged);
  }

  mutation_scope.EnqueueMutationRecord();
}

First, the object is locked. Then the property value is parsed and saved. After this, the lock is released.

Here is how setting the style as a whole looks when using a text string.

/third_party/blink/renderer/core/css/abstract_property_set_css_style_declaration.cc#54

void AbstractPropertySetCSSStyleDeclaration::setCSSText(
    const ExecutionContext* execution_context,
    const String& text,
    ExceptionState&) {
  StyleAttributeMutationScope mutation_scope(this);
  WillMutate();

  const SecureContextMode mode = execution_context
                                     ? execution_context->GetSecureContextMode()
                                     : SecureContextMode::kInsecureContext;
  PropertySet().ParseDeclarationList(text, mode, ContextStyleSheet());

  DidMutate(kPropertyChanged);

  mutation_scope.EnqueueMutationRecord();
}

The same object is locked. Next, the entire text string is parsed and broken down into properties. The values are then saved, and the object is unlocked.

Thus, by calling style.setProperty twice, we initiate the lock-parse-unlock process two times. In contrast, setAttributecan parse the entire style in a single operation.

I must emphasize one particular point regarding style parsing optimization.

/third_party/blink/renderer/core/css/parser/css_parser_impl.cc#255

 static ImmutableCSSPropertyValueSet* CreateCSSPropertyValueSet(
    HeapVector<CSSPropertyValue, 64>& parsed_properties,
    CSSParserMode mode,
    const Document* document) {
  if (mode != kHTMLQuirksMode &&
      (parsed_properties.size() < 2 ||
       (parsed_properties.size() == 2 &&
        parsed_properties[0].Id() != parsed_properties[1].Id()))) {
    // Fast path for the situations where we can trivially detect that there can
    // be no collision between properties, and don't need to reorder, make
    // bitsets, or similar.
    ImmutableCSSPropertyValueSet* result =
        ImmutableCSSPropertyValueSet::Create(parsed_properties, mode);
    parsed_properties.clear();
    return result;
  }
  
  ...
}

The parser, after processing a string, produces an array of properties. However, this array may contain duplicate properties. To create a set of unique properties, it is necessary to iterate over the array again and gather the unique ones. However, if the original array contains only two properties, checking for uniqueness can be done simply with an if statement by comparing them by their IDs. It may seem trivial, but it's quite satisfying.

Test 3: Multiple Properties

Let’s conduct another test. This time, we will use a larger number of properties — say, seven. Why seven specifically? This number is chosen for a reason, which I will explain shortly.

async function handleSetProperty() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.style.setProperty("background-color", "red");
    el.style.setProperty("border", "1px solid blue");
    el.style.setProperty("position", "relative");
    el.style.setProperty("display", "flex");
    el.style.setProperty("align-items", "center");
    el.style.setProperty("text-align", "center");
    el.style.setProperty("text-transform", "uppercase");
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

async function handleSetAttribute() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.setAttribute(
      "style",
      "background-color: red; border: 1px solid blue; position: relative; display: flex; align-items: center; text-align: center; text-transform: uppercase;",
    );
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

The results of this test are quite logical.

avgSetProperty     0.17899999499320984
avgSetAttribute    0.12500000596046448

This time, setAttribute achieved a clear victory in speed for the reasons mentioned above.

Test 4: Multiple Numeric Values

You may have noticed that in the previous test, all properties had string values. The reason for this is that numeric values coming from JavaScript do not require additional parsing from strings. This allows for further optimization and some savings in processing.

Unfortunately, there are not many properties that can handle numeric values in this way. Most of the properties we are familiar with that can accept numeric values are designed to work with various units of measurement, perform different mathematical operations, etc. As a result, it is necessary to convert their numeric values into other formats in any case. Therefore, developers have limited the set of properties that can accept numeric values. Currently, there are only seven such properties: opacity, fill-opacity, flood-opacity, stop-opacity, stroke-opacity, shape-image-threshold, and -webkit-box-flex.

Let’s repeat our previous experiment, but this time focusing solely on numeric properties. This is precisely why I selected only seven properties last time, allowing us to effectively compare the results.

async function handleSetProperty() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.style.setProperty("opacity", 0.5);
    el.style.setProperty("fill-opacity", 0.5);
    el.style.setProperty("flood-opacity", 0.5);
    el.style.setProperty("stop-opacity", 0.5);
    el.style.setProperty("stroke-opacity", 0.5);
    el.style.setProperty("shape-image-threshold", 0.5);
    el.style.setProperty("-webkit-box-flex", 1);

    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

async function handleSetAttribute() {
  const durations = [];

  for await (const i of new Array(N).fill(true).map((_, index) => index)) {
    const el = createElement();

    await sleep(300);

    const startTime = performance.now();
    el.setAttribute(
      "style",
      "opacity: 0.5; fill-opacity: 0.5; flood-opacity: 0.5; stop-opacity: 0.5; stroke-opacity: 0.5; shape-image-threshold: 0.5; -webkit-box-flex: 1;",
    );
    const duration = performance.now() - startTime;

    durations.push(duration);

    await sleep(300);

    console.log(i);
  }

  console.log(durations);
}

And now for the results.

avgSetProperty     0.17099999666213989
avgSetAttribute    0.09600000023841858

The setAttribute method remains faster. Ultimately, the overhead of converting a string to a number does not compare to the performance cost of locking, unlocking, and writing a value. Nevertheless, in both cases, the results turned out to be slightly better than in Test 3.

Conclusion

Let’s summarize everything discussed above.

  1. The style.setProperty method demonstrates better performance when setting a single property. However, when two or more properties are set simultaneously, setAttribute is faster.
  2. The setAttribute method does not validate values, which can be both an advantage and a disadvantage. If it is important for us to ensure that only valid values are set, we can use the setter element.style = "<style>". However, if we need to set a custom attribute on an element or an intentionally incorrect value, setAttribute is indispensable.
  3. There are seven properties that are allowed to accept numeric values from JavaScript scripts. Setting the values of these properties will be slightly faster due to the elimination of the string-to-number conversion operation.


My telegram channels:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

Русская версия: https://blog.frontend-almanac.ru/style-setproperty-vs-setattribute