Dari 57 Performance Dasar, Metrik, dan Cara Ngukur: TTFB itu Liquid kamu. Shopify ngerender template di server tiap request, dan waktu yang dihabisin buat ngeksekusi Liquid langsung nentuin TTFB, fondasi semua metrik lain. Browser gak bisa mulai parse HTML, nemuin gambar, atau jalanin script sampai server selesai. Optimasi sisi klien gak nurunin TTFB, cuma ngurangin waktu render Liquid yang bisa.

Sumber Liquid lambat yang umum: loop yang biayanya tumbuh seiring ukuran katalog (terutama loop bersarang dan akses metafield di dalam loop), query database yang gak perlu karena ngiterasi varian atau produk lebih dari yang ditampilin, dan operasi mahal yang diulang tiap iterasi. Jalankan Theme Inspector (68 Theme Inspector dan Lighthouse CI) buat nemuin baris Liquid mana yang paling lambat sebelum optimasi.

Loop Bersarang: Masalah Terbesar

Loop bersarang punya biaya perkalian: tiap iterasi loop luar ngejalanin seluruh loop dalam, jadi totalnya iterasi luar × iterasi dalam. 10 produk × 10 varian = 100 operasi, 50 produk × 10 varian = 500, dan tiap iterasi mungkin ngakses object Liquid lain lagi. Ini O(n²): gandain ukuran koleksi, kerja render jadi empat kali lipat. Biayanya tumbuh lebih cepat dari kontennya, makanya theme yang cepat di 10 produk bisa lambat di 100. Ini penyebab TTFB tinggi paling umum.

Solusinya: ratakan struktur loop dengan menghitung dulu apa yang kamu butuhin sebelum loop luar mulai, pakai filter array dari 18 Filters String, Math, dan Array.

1. where dan first buat Nyari Kecocokan

Kalau loop dalam nyari item pertama yang memenuhi kondisi, ganti dengan where yang di-assign sebelum loop:

{%- comment -%} Less performant: an interpreted loop and condition for every product scanned {%- endcomment -%}
{% for collection in collections %}
  {% for product in collection.products %}
    {% if product.available %}
      {{ product.featured_image | image_url: width: 400 | image_tag }}
      {% break %}
    {% endif %}
  {% endfor %}
{% endfor %}
{%- comment -%} More performant: one filter call per collection does the same scan {%- endcomment -%}
{% for collection in collections %}
  {% assign in_stock = collection.products | where: "available", true | first %}
  {% if in_stock %}
    {{ in_stock.featured_image | image_url: width: 400 | image_tag }}
  {% endif %}
{% endfor %}

Dua-duanya lihat produk yang sama, bedanya di mana kerjanya: for ngevaluasi tag, kondisi, dan iterasi buat tiap produk, sedangkan where ngelakuin perbandingan dalam satu panggilan filter (yang jalan di Rust, jauh lebih cepat dari Liquid yang diinterpretasi). Penulisan ulangnya paling berharga waktu kecocokan jarang atau gak ada, karena loop-nya bakal jalan penuh. Kalau item pertama hampir selalu cocok, loop break di iterasi pertama dan keduanya mirip.

2. map buat Bikin Lookup

Kalau loop dalam ngecek keanggotaan di array lain, pakai map buat ngekstrak nilai yang dibutuhin sebelum loop luar, terus contains buat lookup langsung:

{%- comment -%} Less performant: inner loop re-scans all cart items on every variant iteration {%- endcomment -%}
{% for variant in product.variants %}
  {% assign in_cart = false %}
  {% for item in cart.items %}
    {% if item.variant_id == variant.id %}
      {% assign in_cart = true %}
      {% break %}
    {% endif %}
  {% endfor %}
  {% if in_cart %}In cart{% endif %}
{% endfor %}
{%- comment -%} More performant: build cart ID lookup once, then check contains {%- endcomment -%}
{% assign cart_variant_ids = cart.items | map: "variant_id" %}
{% for variant in product.variants %}
  {% if cart_variant_ids contains variant.id %}
    In cart
  {% endif %}
{% endfor %}

3. join buat Output Daftar

Kalau loop dalam cuma nggabungin nilai dengan pemisah, join ngelakuin hal yang sama tanpa iterasi bersarang:

{%- comment -%} Less performant: inner loop over variant.options on every variant iteration {%- endcomment -%}
{% for variant in product.variants %}
    {% for option in variant.options %}
      {{ option }}{% unless forloop.last %} / {% endunless %}
    {% endfor %}
{% endfor %}
{%- comment -%} More performant: join outputs the same result without an inner loop {%- endcomment -%}
{% for variant in product.variants %}
  {{ variant.options | join: " / " }}
{% endfor %}

4. Contoh Utuh: Section Daftar Koleksi

Section yang nampilin gambar preview, badge “di cart”, dan ringkasan tag buat tiap koleksi bisa berakhir dengan tiga loop bersarang di dalam satu loop luar:

{% for collection in collections %}
    {% for product in collection.products %}
      {% if product.available %}
        {{ product.featured_image | image_url: width: 400 | image_tag }}
        {% break %}
      {% endif %}
    {% endfor %}
    {% assign in_cart = false %}
    {% for product in collection.products %}
      {% for item in cart.items %}
        {% if item.product_id == product.id %}
          {% assign in_cart = true %}
        {% endif %}
      {% endfor %}
    {% endfor %}
    {% if in_cart %}In your cart{% endif %}
      {% for tag in collection.all_tags %}
        {{ tag }}{% unless forloop.last %}, {% endunless %}
      {% endfor %}
{% endfor %}

Lookup cart itu yang paling mahal karena satu-satunya loop yang gak bisa keluar lebih awal: dia bandingin tiap produk dengan tiap baris cart. Bangun ID produk cart sekali, di atas loop luar, dan jadi satu cek contains:

{% assign cart_product_ids = cart.items | map: "product_id" %}
{% for collection in collections %}
  {% assign in_stock = collection.products | where: "available", true | first %}
  {% assign collection_product_ids = collection.products | map: "id" %}
    {% if in_stock %}
      {{ in_stock.featured_image | image_url: width: 400 | image_tag }}
    {% endif %}
    {% assign in_cart = false %}
    {% for product_id in cart_product_ids %}
      {% if collection_product_ids contains product_id %}
        {% assign in_cart = true %}
        {% break %}
      {% endif %}
    {% endfor %}
    {% if in_cart %}In your cart{% endif %}
    {{ collection.all_tags | join: ", " }}
{% endfor %}

Loop yang tersisa ngiterasi baris cart, daftar pendek yang dibatasi apa yang pembeli tambahin, bukan katalog. Dan cart_product_ids di-assign sekali buat seluruh section, bukan per koleksi, karena cart gak berubah selama render.

5. Lookup yang Dibagi Banyak Block

Kalau lebih dari satu block butuh daftar turunan yang sama, assign sekali di atas section dan biarin tiap block baca. Biayanya tetap berapapun block yang merchant tambahin:

{% assign cart_variant_ids = cart.items | map: "variant_id" %}
{% assign available_variants = product.variants | where: "available", true %}
{% for block in section.blocks %}
  {% case block.type %}
    {% when 'variant_picker' %}
      {% for variant in available_variants %}
          {{ variant.title }}
          {% if cart_variant_ids contains variant.id %}In cart{% endif %}
      {% endfor %}
    {% when 'stock_note' %}
      {{ available_variants.size }} of {{ product.variants.size }} options in stock.
  {% endcase %}
{% endfor %}

Metafield di Luar Loop

Masalah performa metafield paling umum: akses di dalam loop bersarang. Tiap akses metafield query ke backend, dikali varian per produk, dikali produk per koleksi. 50 produk × 10 varian = 500 lookup metafield minimum, yang bisa nambah detik ke TTFB.

Loop metaobject lebih mahal lagi: ngeloop entri metaobject nambah overhead signifikan, tiap entri query backend. Loop tanpa paginasi ngasih maksimal 50 entri, dan ngebungkusnya di paginate bikin bisa lebih jauh, yang nambah kerja backend, bukan ngurangin. Pakai loop metaobject cuma kalau halaman butuh.

1. Akses Sekali

Kalau metafield gak berubah antar iterasi, baca sekali sebelum loop:

{%- comment -%} Bad: reads the same collection metafield twice on every iteration {%- endcomment -%}
{% for product in collection.products %}
  {% if collection.metafields.custom.badge_label != blank %}
    {{ collection.metafields.custom.badge_label }}
  {% endif %}
  {{ product.title }}
{% endfor %}
{%- comment -%} Good: reads the collection metafield once, before the loop {%- endcomment -%}
{% assign badge_label = collection.metafields.custom.badge_label %}
{% for product in collection.products %}
  {% if badge_label != blank %}
    {{ badge_label }}
  {% endif %}
  {{ product.title }}
{% endfor %}

Metafield yang berubah tiap iterasi (product.metafields.custom.featured) gak bisa diangkat. Buat itu, kurangi jumlah lookup per iterasi dengan tetap di level produk, bukan varian.

2. Hindari Metafield Level Varian

{%- comment -%} Bad: checks metafields for every variant {%- endcomment -%}
{% for variant in product.variants %}
  {% if variant.metafields.custom.field1 %}...{% endif %}
  {% if variant.metafields.custom.field2 %}...{% endif %}
  {% if variant.metafields.custom.field3 %}...{% endif %}
{% endfor %}
{%- comment -%} Good: checks only the selected variant {%- endcomment -%}
{% assign selected = product.selected_or_first_available_variant %}
{% if selected.metafields.custom.field1 %}...{% endif %}
{% if selected.metafields.custom.field2 %}...{% endif %}

Dan kalau datanya sebenarnya milik produk (size guide), taruh di metafield produk, cek sekali di luar loop varian.

3. Batasi Loop Metaobject

{%- comment -%} Good: limit the metaobject loop {%- endcomment -%}
{% for testimonial in metaobjects.testimonials.values limit: 3 %}
  {{ testimonial.author.value }}
{% endfor %}

Pakai object level atas metaobjects (dari 15 Objects Konten > Metaobject), bentuk shop.metaobjects udah deprecated.

4. Pindahin ke Theme Setting

Buat data yang gak berubah per produk, pakai theme setting, dicek sekali di luar loop:

{%- comment -%} Good: checks the theme setting once, outside the loop {%- endcomment -%}
{% if settings.show_new_badge %}
  {% for product in collection.products %}
    New
  {% endfor %}
{% endif %}

Jebakan tanggal: gak ada tipe setting tanggal, jadi setting-nya string, dan bandingin langsung sama product.created_at bikin error comparison of ActiveSupport::TimeWithZone with String failed. Normalisasi dua-duanya ke Unix timestamp:

{%- comment -%} Good: compare Unix timestamps, not a date to a string {%- endcomment -%}
{% assign new_after = settings.new_product_date | date: '%s' | times: 1 %}
{% for product in collection.products %}
  {% assign created_at = product.created_at | date: '%s' | times: 1 %}
  {% if created_at > new_after %}
    New
  {% endif %}
{% endfor %}

Pengingat perbedaannya: metafield memperluas sumber daya yang ada (produk, varian, koleksi), metaobject bikin sumber daya baru (testimoni, FAQ). Dua-duanya mahal di loop. Metafield buat data spesifik produk, metaobject hemat-hemat buat konten kustom yang dikelola terpisah.

Overfetching Varian

Ngiterasi product.variants maksa server ngeload dan memproses semua data varian waktu render. Buat produk dengan ratusan atau ribuan varian (Shopify ndukung sampai 2.048), ini naikin TTFB secara signifikan. Padahal kebanyakan halaman produk cuma butuh nama dan nilai opsi buat pemilih, bukan data varian penuh tiap kombinasi.

Solusinya udah dibahas tuntas di 39 Produk Variants dan High-variant Products: product.options_with_values ngasih data opsi tanpa ngeload object varian, dengan ketersediaan lewat product_option_value.available dan state lewat .selected, jalan buat berapapun varian. Yang boleh: ngecek properti tanpa iterasi (product.variants.size > 1, product.variants.first.price, product.variants_count, selected_or_first_available_variant). Yang gak: {% for variant in product.variants %} di form produk.

Waktu opsi dipilih, ambil info varian lewat Section Rendering API dengan option_values (bisa urutan bebas, bisa sebagian, yang gak dikirim jatuh ke nilai pertama yang tersedia). Satu detail penting: scope lookup ke pemilih yang nembak event pakai event.target.closest(), karena querySelector seluruh dokumen ngembaliin kecocokan pertama, yang salah section kalau halaman ngerender lebih dari satu produk:

function updateVariantInfo(event) {
  const selectors = event.target.closest('.option-value-selectors');
  const sectionId = selectors.dataset.sectionId;
  const optionValues = Array.from(
    selectors.querySelectorAll('input[type="radio"]:checked')
  )
    .map(({dataset}) => dataset.optionValueId)
    .join(',');
  fetch(`${window.location.pathname}?section_id=${sectionId}&option_values=${optionValues}`)
    .then((response) => response.text())
    .then((responseText) => {
      const html = new DOMParser().parseFromString(responseText, 'text/html');
      const productInfo = document.getElementById(`ProductInfo-${sectionId}`);
      productInfo.innerHTML = html.querySelector(`#ProductInfo-${sectionId}`).innerHTML;
      selectors.innerHTML = html.querySelector('.option-value-selectors').innerHTML;
    });
}

Tes dengan produk 100+ varian, dan bandingin waktu render produk 10 varian vs 1.000 varian.

1. Combined Listing

Kasus khusus (Plus, online store aja): combined listing itu produk induk yang ngehubungin beberapa produk anak jadi satu item. Tiap anak itu produk terpisah dengan varian, gambar, dan URL sendiri, jadi ngerender semuanya di muka ngalikan query backend. Induk dengan 20–60 anak bisa nambah detik ke TTFB. Batasnya: 2.000 varian di semua anak, 60 anak per induk, tiga opsi di induk.

Polanya sama: render nilai opsi lewat options_with_values, dan pakai product_option_value.product_url buat pindah ke produk saudara, satu-satunya mekanisme Liquid buat navigasi combined listing. product_url nil kecuali produk sekarang itu induk combined listing, dan juga nil buat nilai opsi yang dimiliki anak (warna dari induk ngasih URL saudara, ukuran dari anak ngasih nil). Gak ada properti Liquid yang bilang “ini combined listing”, cabangkan berdasarkan product_url: ada → navigasi/render produk itu, kosong → render ulang halaman sekarang:

function onOptionValueChange(event) {
  const selectors = event.target.closest('.option-value-selectors');
  const sectionId = selectors.dataset.sectionId;
  const productUrl = event.target.dataset.productUrl;
  const selectedOptionValues = Array.from(
    selectors.querySelectorAll('input[type="radio"]:checked')
  ).map(({dataset}) => dataset.optionValueId);
  const params = selectedOptionValues.length > 0
    ? `&option_values=${selectedOptionValues.join(',')}`
    : '';
  // An empty product URL means the option value belongs to the current
  // product, so re-render the current page instead of a sibling product.
  const basePath = productUrl || window.location.pathname;
  fetch(`${basePath}?section_id=${sectionId}${params}`)
    .then((response) => response.text())
    .then((responseText) => {
      const html = new DOMParser().parseFromString(responseText, 'text/html');
      // Scope the update to the component that emitted the event. A bare
      // `#product-info` lookup always returns the first match in the document,
      // so a second product component on the page updates the wrong one.
      document.getElementById(`product-info-${sectionId}`).innerHTML =
        html.querySelector('.product-info').innerHTML;
      selectors.innerHTML = html.querySelector('.option-value-selectors').innerHTML;
    });
}

Batas Paginasi

Paginasi dalam itu mahal: harus memindai ribuan object, nambah waktu query, overhead server, dan bisa ngelambatin request lain di seluruh platform. Batasnya 25.000 object (dan count jadi 25.001 di atasnya), berlaku buat koleksi, hasil pencarian, dan array Liquid apapun.

Daripada paginasi dalam, bantu pembeli nyempitin lewat filter dari 37 Filtering. Strategi yang wajar:

  1. 12–24 produk per halaman buat keseimbangan performa dan UX.
  2. 50 per halaman batas atas yang masuk akal (maksimal 500 halaman).
  3. Dorong filter kalau pembeli sampai halaman 3+, dan promosikan pencarian buat katalog besar.

Platform maksimum paginate 250 per halaman (nilai di atasnya dipotong ke 250, bukan ditolak), metaobject punya batas sendiri 1.000. Theme Check PaginationSize default maxSize 250. Makin jauh di bawah maksimum, makin cepat responsnya.

Infinite scroll buat koleksi besar: pakai IntersectionObserver di elemen sentinel di akhir grid, bukan listener scroll yang nembak tiap frame:

let currentPage = 1;
let loading = false;
const grid = document.querySelector(".product-grid");
const sentinel = document.querySelector(".product-grid-sentinel");
const observer = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting || loading) return;
  loading = true;
  currentPage++;
  try {
    // Start from the current URL so active `filter.*` and `sort_by` parameters survive.
    // Building the request from `pathname` alone would append products from the
    // unfiltered collection into a filtered grid.
    const url = new URL(window.location.href);
    url.searchParams.set("page", currentPage);
    url.searchParams.set("section_id", "product-grid");
    const response = await fetch(url);
    const html = new DOMParser().parseFromString(
      await response.text(),
      "text/html"
    );
    const products = html.querySelectorAll(".product-item");
    if (products.length === 0) {
      observer.disconnect();
      return;
    }
    grid.append(...products);
  } finally {
    loading = false;
  }
});
observer.observe(sentinel);

Perhatiin komentarnya: URL dibangun dari window.location.href biar parameter filter dan sort yang aktif ikut, kalau dari pathname aja, produk tanpa filter nempel ke grid yang udah difilter. Infinite scroll tetap kena batas 25.000, sediain tombol Load more sebagai fallback, dan pikirin aksesibilitasnya.

render Bersarang

Tiap {% render %} punya biaya tetap: Liquid nyari file snippet, bikin scope baru, ngirim variabel, ngeksekusi, ngembaliin hasil. Kecil buat satu render, tapi menumpuk waktu bersarang: snippet yang ngerender snippet yang ngerender snippet ketiga = tiga lapis overhead per panggilan. Di dalam loop, ini berlipat: kartu produk yang ngerender snippet harga dan snippet badge, di 50 produk = 150 render. Diratakan ke satu tingkat = 50.

Dan jangan pakai include: deprecated, dan karena ngasih snippet akses ke seluruh scope induk (dan bisa ngubah variabel pemanggil), Liquid gak bisa menerapkan optimasi yang dia terapin ke render. include lebih lambat bahkan sebelum nesting ngalikan biayanya. Ganti semua dengan render dan kirim variabel eksplisit (12 Tags Khusus Theme > render).

1. Caranya

  1. Identifikasi kedalaman lewat flame graph Theme Inspector, tiga tingkat atau lebih itu tanda peluang.
  2. Inline markup sederhana: snippet yang cuma beberapa baris HTML tanpa logika kondisional, masukin langsung ke induk. Sisakan render buat snippet dengan logika substansial atau yang dipakai ulang di banyak section.
  3. Gabungkan snippet bersarang: kartu produk yang manggil product-price dan product-badge sering bisa jadi satu file.
  4. Pola skip_styles buat snippet yang tetap di-render (kartu produk di banyak section), biar stylesheet-nya gak dimuat tiap iterasi:
{%- assign skip_card_product_styles = false -%}
{%- for product in collection.products -%}
  {% render 'card-product',
    product: product,
    skip_styles: skip_card_product_styles
  %}
  {%- assign skip_card_product_styles = true -%}
{%- endfor -%}

Jebakan: argumen render gak bisa berisi perbandingan. skip_styles: forloop.index > 1 di-parse sebagai skip_styles: forloop.index dan > 1 dibuang diam-diam. Snippet nerima angka, semua angka truthy, jadi {% unless skip_styles %} gak pernah jalan dan stylesheet gak pernah dimuat. Assign perbandingannya ke variabel dulu.

❌ perbandingan dibuang diam-diam

{% render 'card-product', product: product, skip_styles: forloop.index > 1 %}

✅ lewat variabel

{% assign skip = forloop.index > 1 %}
{% render 'card-product', product: product, skip_styles: skip %}

2. Contoh

Sebelum, tiga render per produk:

{%- for product in collection.products -%}
  {% render 'product-card', product: product %}
{%- endfor -%}
  {{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
  {{ product.title }}
  {% render 'product-price', product: product %}
  {% render 'product-badge', product: product %}

Sesudah, harga dan badge di-inline karena cuma HTML sederhana dengan kondisional dasar:

{%- unless skip_styles -%}
  {{ 'component-card.css' | asset_url | stylesheet_tag }}
{%- endunless -%}
  {{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
  {{ product.title }}
    {% if product.compare_at_price > product.price %}
      {{ product.price | money }}
      {{ product.compare_at_price | money }}
    {% else %}
      {{ product.price | money }}
    {% endif %}
  {% if product.available == false %}
    Sold out
  {% elsif product.compare_at_price > product.price %}
    Sale
  {% endif %}

Buat snippet kompleks (pemilih varian, galeri media, structured data), tetap terpisah itu wajar. Tujuannya menghindari nesting markup sederhana yang gak dapat manfaat dari abstraksi.

Defer Konten Dialog

Dialog dan drawer yang ngeload konten tiap request ngebuang resource server walaupun pengguna gak pernah bukanya. Operasi mahal yang umum: query koleksi (collections.all.products), iterasi varian, akses metafield, semuanya jalan waktu render awal kalau gak dijaga.

Contoh nyatanya dari Horizon v2.1.4: ngehapus assignment settings.empty_state_collection.products | default: collections.all.products yang gak dijaga di dialog predictive search nghemat 400ms tiap page load, karena itu ngevaluasi product.available, product.price_max, dan properti lain yang harus menilai semua varian. Di toko dengan produk high-variant, collections.all tanpa limit bisa nambah 1–3 detik ke TTFB.

1. Parameter Kondisional

Bikin parameter yang ngontrol apakah konten mahal dimuat:

{% doc %}
  @param {boolean} [load_content] - Whether to load the modal content.
{% enddoc %}
{% if load_content %}
  {% liquid
    assign collection = settings.featured_collection | default: collections.all
    assign title = collection.title
  %}
  {%- for product in collection.products limit: 4 -%}
    {{ product.title }}
  {%- endfor -%}
{% endif %}

2. Load Waktu Dibuka

Render awal nyertain cangkang dialog dengan load_content: false. Waktu dibuka, fetch section khusus yang ngerender snippet dengan load_content: true lewat Section Rendering API:

{%- comment -%}
  Dedicated section used only by the Section Rendering API to return the
  search dialog with its expensive content loaded. It repeats the `#search-modal`
  wrapper because the click handler looks that element up in the response.
{%- endcomment -%}
  {% render 'search-modal-content', load_content: true %}
const searchToggle = document.querySelector('#search-toggle');
let searchModal = document.querySelector('#search-modal');
let contentLoaded = false;
searchToggle.addEventListener('click', async () => {
  if (!contentLoaded) {
    // Fetch the dedicated section that renders the modal with load_content: true.
    const response = await fetch('?sections=search-modal-loaded');
    const data = await response.json();
    const parser = new DOMParser();
    const doc = parser.parseFromString(data['search-modal-loaded'], 'text/html');
    const loadedContent = doc.querySelector('#search-modal');
    searchModal.replaceWith(loadedContent);
    // Re-query the DOM — `searchModal` now references the detached old node.
    searchModal = document.querySelector('#search-modal');
    contentLoaded = true;
  }
  searchModal.hidden = false;
});

Perhatiin contentLoaded biar cuma fetch sekali, dan re-query setelah replaceWith karena referensi lama nunjuk ke node yang udah dilepas. Dan tetap kecilkan query-nya walaupun kondisional: limit: 4 di loop collection.products ngurangin fetch dari 50 ke 4.

Kasus cart drawer dengan rekomendasi: object recommendations cuma terisi lewat endpoint Product Recommendations API, bukan Section Rendering API generik. Jadi render cangkang drawer tanpa rekomendasi, dan fetch section rekomendasi dari endpoint yang benar (/recommendations/products?section_id=...&product_id=...) waktu drawer dibuka, dengan data-product-id yang kosong kalau cart kosong (dan handler lewatin fetch-nya). Bahasannya di 41 Produk Recommendations, Gift Card, dan Bundles.

Filter Sebelum Loop

Cek kondisional punya biaya, dan di dalam loop dikali jumlah iterasi. 50 produk × 3 cek = 150 evaluasi. Kalau bisa pre-filter ke 12 produk yang cocok sebelum loop, jadi 12 iterasi × 1 cek. Kondisi kompleks dengan banyak and/or lebih mahal karena tiap operator evaluasi terpisah.

Dasar:

{% assign available_products = collection.products | where: "available" %}
{%- for product in available_products -%}
  {{ product.title }} - {{ product.price | money }}
{%- endfor -%}

Gabungan: terapin sebanyak mungkin lewat filter, sisanya satu cek di loop:

{% assign candidates = collection.products | where: "available" %}
{%- for product in candidates -%}
  {% if product.tags contains 'featured' and product.price >= 1000 and product.price <= 5000 %}
      {{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
      {{ product.title }}
      {{ product.price | money }}
  {% endif %}
{%- endfor -%}

Lookup yang dihitung dulu buat ngecek produk terhadap daftar nilai:

{% assign promoted_handles = section.settings.promoted_products | map: "handle" %}
{%- for product in collection.products -%}
  {% if promoted_handles contains product.handle %}
      {{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
      {{ product.title }}
  {% else %}
      {{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
      {{ product.title }}
  {% endif %}
{%- endfor -%}

Rantai filter biar body loop tanpa kondisional sama sekali:

{% assign sorted_available = collection.products | where: "available" | sort: "price" %}
{%- for product in sorted_available -%}
    {{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
    {{ product.title }}
    {{ product.price | money }}
{%- endfor -%}

Prinsip terkait dari Shopify: filter di Liquid, bukan bikin banyak query database. Kalau beberapa section atau snippet pakai data yang sama tapi difilter beda, jangan restrukturisasi jadi query terpisah per tampilan. Filtering Liquid jalan di Rust dan sangat cepat, sedangkan tiap query database nambah overhead jaringan. Ambil sekali, filter di Liquid.

Hoist Operasi ke Luar Loop

Operasi di dalam loop yang gak bergantung ke variabel loop ngitung hal yang sama berulang. sort di dalam loop 50 produk jalan 50 kali dengan hasil identik. Yang umum bisa diangkat: assign dengan nilai statis, blok capture yang outputnya sama tiap iterasi, image_url gambar yang gak berubah (placeholder, logo), asset_url (path aset selalu sama), sort/where, money di nilai tetap, size array yang gak berubah.

Kartu produk dengan empat operasi yang bisa diangkat:

{%- for product in collection.products -%}
  {% assign placeholder = 'product-1' | placeholder_svg_tag %}
  {% assign badge_icon = 'icon-badge.svg' | asset_url %}
  {% assign free_shipping_threshold = section.settings.free_shipping_threshold | money %}
  {% assign total_products = collection.products | size %}
    {% if product.featured_image != blank %}
      {{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
    {% else %}
      {{ placeholder }}
    {% endif %}
    {{ product.title }}
    {{ product.price | money }}
    {% if product.price >= section.settings.free_shipping_threshold %}
      Free shipping on orders over {{ free_shipping_threshold }}
    {% endif %}
    {{ forloop.index }} of {{ total_products }}
{%- endfor -%}

Diangkat:

{% assign placeholder = 'product-1' | placeholder_svg_tag %}
{% assign badge_icon = 'icon-badge.svg' | asset_url %}
{% assign free_shipping_threshold_formatted = section.settings.free_shipping_threshold | money %}
{% assign total_products = collection.products | size %}
{%- for product in collection.products -%}
    {% if product.featured_image != blank %}
      {{ product.featured_image | image_url: width: 400 | image_tag: loading: 'lazy' }}
    {% else %}
      {{ placeholder }}
    {% endif %}
    {{ product.title }}
    {{ product.price | money }}
    {% if product.price >= section.settings.free_shipping_threshold %}
      Free shipping on orders over {{ free_shipping_threshold_formatted }}
    {% endif %}
    {{ forloop.index }} of {{ total_products }}
{%- endfor -%}

50 produk = 200 evaluasi filter lebih sedikit. capture juga sama: blok yang ngasilin string identik tiap iterasi (empty state dengan terjemahan) di-capture sekali di atas loop.

section.index: Atas vs Bawah Lipatan

Dulu developer gak bisa tau posisi section, yang ngasilin anti-pola: lazy-load semua gambar termasuk LCP, async semua CSS termasuk yang above-the-fold, tanpa fetch priority karena section pertama gak bisa dikenali. Tiga properti ini ngasih optimasi sadar posisi:

  1. section.index: posisi mulai dari 1.
  2. section.index0: mulai dari 0.
  3. section.location: template, static, content_for_index, preset, tipe section group (header, footer, aside), atau custom. buat section group kustom.

1. Jebakan nil

section.index dan index0 itu nil di tiga konteks: section statis ({% section 'name' %} dari theme.liquid), di theme editor, dan lewat Section Rendering API. Dan perbandingan dengan nil itu falsey: nil <= 2 gak error, gak true, tapi false. Jadi {% if section.index <= 2 %} masuk cabang else, dan kalau else-nya lazy-load, gambar LCP kamu lazy-load di theme editor. Kalau gak ada else, gambarnya gak dirender sama sekali.

Tulis kondisinya biar nil jatuh ke sisi aman: tes kasus below-the-fold sebagai perbandingan positif section.index > N, dan biarin semua kasus lain termasuk nil jatuh ke cabang eager:

{% unless section.index > 2 %}
  {%- comment -%} Sections 1 and 2, and any context where section.index is nil {%- endcomment -%}
  {{ image | image_url: width: 1000 | image_tag: loading: 'eager' }}
{% else %}
  {%- comment -%} Sections 3 and later {%- endcomment -%}
  {{ image | image_url: width: 1000 | image_tag: loading: 'lazy' }}
{% endunless %}

Default image_tag sendiri udah begini: tanpa argumen loading, dia eager buat tiga section pertama dan kapanpun section.index0 nil. Gating manual dengan <= ngasilin perilaku kebalikan persis di konteks di mana default bawaannya hati-hati.

❌ nil jatuh ke lazy, LCP lazy-load di editor

{% if section.index <= 2 %}eager{% else %}lazy{% endif %}

✅ nil jatuh ke eager

{% unless section.index > 2 %}eager{% else %}lazy{% endunless %}

2. Hitungan per Lokasi

Counter restart di 1 tiap lokasi: section group, template JSON, dan content_for_index masing-masing punya penomoran sendiri. Section pertama footer group dan section pertama template dua-duanya index 1, dan section yang dinonaktifkan gak ngonsumsi index. Jadi section.index == 1 berarti “pertama di lokasinya”, bukan “pertama di halaman”. Gabungkan dengan section.location kalau butuh posisi level halaman, misalnya section.location == 'header' yang selalu above the fold.

3. Strategi Lengkap

{% if section.index == 1 %}
  {%- comment -%} Hero section: maximum priority {%- endcomment -%}
  {{ section.settings.image
    | image_url: width: 2000
    | image_tag:
        loading: 'eager',
        fetchpriority: 'high',
        widths: '400, 600, 800, 1000, 1200, 1600, 2000',
        sizes: '100vw',
        class: 'hero-image'
  }}
{% elsif section.index > 3 %}
  {%- comment -%} Sections 4 and later: lazy load {%- endcomment -%}
  {{ section.settings.image
    | image_url: width: 1000
    | image_tag:
        loading: 'lazy',
        widths: '400, 600, 800, 1000',
        sizes: '(min-width: 1000px) 900px, calc(100vw - 2rem)'
  }}
{% else %}
  {%- comment -%} Sections 2 and 3, and nil contexts: eager load, normal priority {%- endcomment -%}
  {{ section.settings.image
    | image_url: width: 1000
    | image_tag:
        loading: 'eager',
        widths: '400, 600, 800, 1000',
        sizes: '(min-width: 1000px) 900px, calc(100vw - 2rem)'
  }}
{% endif %}

Pola yang sama buat CSS: unless section.index > 3 → stylesheet render-blocking, else → async CSS (60 Performance CSS dan Font). Tes di storefront, bukan editor (index nil di sana), urutin ulang section dan cek logikanya, dan buka editor buat mastiin semua gambar tetap render (gambar hilang = ada kondisi yang gak nanganin nil).

Section Rendering API buat Update Dinamis

Tanpa ini, update konten butuh reload penuh yang ngunduh dan ngerender ulang seluruh halaman. Section Rendering API cuma minta section yang berubah, jadi update terasa instan dan metrik halaman berikutnya membaik. Detail API-nya di 51 Ajax API dan Section Rendering API, ini catatan performanya:

  1. Lebih dari lima section di satu request → 400 dan gak ada yang dirender.
  2. Jangan hardcode ID section di template JSON/section group. Minta product-info pakai nama file, dan server ngerender versi statis dengan setting default (atau gagal), yang ngeganti konten halaman yang udah dikonfigurasi dengan markup default. Baca dari section.id atau pembungkusnya.
  3. ID section harus ada di template yang dirender buat URL yang diminta: ID section produk jalan buat produk lain dengan template sama, tapi gak buat URL koleksi.
  4. Optimistic cart rendering: waktu add to cart, drawer biasanya kosong sampai server merespons. Bikin terasa instan dengan langsung nyisipin item ke drawer pakai data yang udah ada di halaman produk (judul, varian, harga, gambar, jumlah), terus rekonsiliasi waktu respons server datang (diskon, stok, promosi level cart yang cuma server tau):
function addToCart(variantId, quantity) {
  const product = getProductDataFromPage();
  // Immediately render optimistic cart item
  renderOptimisticCartItem({
    title: product.title,
    variant: product.selectedVariant,
    price: product.price,
    image: product.featuredImage,
    quantity: quantity,
  });
  openCartDrawer();
  // Fetch the real server-rendered cart sections
  fetch("/cart/add.js", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      items: [{ id: variantId, quantity }],
      sections: "cart-drawer,cart-icon-bubble",
    }),
  })
    .then((response) => response.json())
    .then((data) => {
      // Replace optimistic content with server-rendered HTML
      replaceCartSections(data.sections);
    });
}

Ini misahin responsivitas UI yang dirasain dari waktu pemrosesan server.

Mikro-optimasi

Individual kecil (1–5ms), tapi di theme kompleks dengan 50 section dan banyak block, semuanya bareng bisa nghemat 25–50ms TTFB. Prioritaskan yang berdampak besar dulu, ukur pakai Theme Inspector.

1. Cache Hasil Filter

Beberapa filter punya biaya. Satu panggilan mikrodetik, tapi Theme Inspector bisa nunjukin filter yang dipanggil ribuan kali dalam satu render, terakumulasi 100ms+. Contoh: filter:image_url 50µs per panggilan × 2.000 panggilan = 100ms. Simpan hasilnya dengan assign dan pakai ulang:

{%- for product in collection.products -%}
  {% assign product_image = product.featured_image | image_url: width: 300 %}
{%- endfor -%}

Di Theme Inspector: bikin flame graph, ganti ke sandwich view, urutkan Self time, cari operasi yang dipanggil ratusan atau ribuan kali.

2. block.settings ke Variabel

Tiap akses block.settings.x bikin Liquid nyelesaiin rantai properti (block → settings → key). Section dengan 20 block × 5 setting = 100 lookup penuh. Dengan assign di awal tiap iterasi: 20 assign dan 100 pembacaan variabel yang lebih murah. Berlaku juga buat section.settings:

{%- for block in section.blocks -%}
  {%- assign block_settings = block.settings -%}
    {% if block_settings.image != blank %}
      {{ block_settings.image
        | image_url: width: 800
        | image_tag:
            loading: 'lazy',
            widths: '400, 600, 800',
            sizes: '(min-width: 768px) 50vw, 100vw',
            alt: block_settings.image_alt
      }}
    {% endif %}
      {% if block_settings.heading != blank %}
        {{ block_settings.heading }}
      {% endif %}
      {% if block_settings.subheading != blank %}
        {{ block_settings.subheading }}
      {% endif %}
      {% if block_settings.button_label != blank %}
          {{ block_settings.button_label }}
      {% endif %}
{%- endfor -%}

Paling terasa di homepage dengan slideshow, FAQ dengan banyak item, dan section apapun yang ngiterasi section.blocks.

3. echo daripada append/prepend

Tiap filter nambah overhead kecil. Buat penggabungan string sederhana yang langsung di-output, output langsung menghindarinya. Kartu produk dengan 5 append × 50 render = 250 panggilan filter yang bisa nol:

{%- comment -%} Less performant: uses append filter {%- endcomment -%}
{%- assign greeting = "Hello " | append: customer.first_name -%}
{{ greeting }}
{%- comment -%} More performant: direct output {%- endcomment -%}
Hello {{ customer.first_name }}

Di dalam {% liquid %}, echo satu-satunya cara output ({{ }} gak tersedia):

{%- liquid
  echo product.vendor
  echo " - "
  echo product.title
-%}

Class CSS: output langsung dengan kondisional inline, bukan assign + append berulang:

<div class="product-card
  {%- if product.available == false %} product-card--sold-out{% endif -%}
  {%- if product.compare_at_price > product.price %} product-card--on-sale{% endif -%}
">

Structured data di loop, dua jebakan sekaligus: urutan filter di '"price": "' | append: product.price | money_without_currency bikin money_without_currency diterapin ke seluruh string (parse jadi 0.0, render 0.00 diam-diam), dan money_without_currency buat price schema.org salah karena locale dengan koma desimal ngasilin nilai gak valid. Output desimal polos:

{%- for product in collection.products -%}
  {
    "@type": "Product",
    "name": {{ product.title | json }},
    "price": "{{ product.price | divided_by: 100.0 }}"
  }
{%- endfor -%}

assign + append tetap tepat kalau hasilnya dipakai di banyak tempat atau transformasinya kompleks.

4. limit vs paginate

Tiap produk yang di-load array Liquid bawa query buat varian, gambar, harga, ketersediaan. Fetch 50 buat render 4 ngalikan query itu. Cara menghindarinya tergantung array-nya:

  1. collection.products dan collections: limit di for cukup, fetch-nya nyusut sesuai limit (kalau loop gak di dalam paginate dan limit di bawah ukuran halaman default: 50 buat produk, 1.000 buat koleksi). Tanpa limit, fetch seluruh ukuran default.
{%- comment -%} Fetches 4 products, not 50 {%- endcomment -%}
{% for product in collection.products limit: 4 %}
  {{ product.title }}
{% endfor %}
  1. Array lain (blog.articles, search.results, product.variants, pages, customer.orders, customer.addresses, article.comments): limit cuma ngontrol iterasi, paginate yang ngontrol fetch. Pakai dua-duanya:
{%- comment -%} Fetches 4 articles {%- endcomment -%}
{% paginate blog.articles by 4 %}
  {% for article in blog.articles limit: 4 %}
    {{ article.title }}
  {% endfor %}
{% endpaginate %}

Peringatan: jangan pakai paginate buat carousel atau baris featured products yang ukurannya tetap. Blok paginate merespons parameter URL page, jadi section yang dibungkusnya ngerender item berbeda (atau kosong) di ?page=2. limit buat daftar ukuran tetap, paginate buat daftar yang beneran di-paging pembeli.

Batas yang disarankan per tipe section: featured products 4–6, carousel (load awal) 4–8, rekomendasi 2–4, grid 8–12, koleksi paginasi 24–50.

Ringkasan

  1. TTFB = waktu render Liquid, optimasi klien gak nyentuhnya. Profil dulu pakai Theme Inspector (flame graph, sandwich view, Self time).
  2. Loop bersarang itu O(n²): ratakan dengan where | first (cari kecocokan), map + contains (keanggotaan), join (output daftar), dan hitung lookup sekali di atas loop/section, bukan per iterasi atau per block.
  3. Metafield: akses sekali di luar loop, hindari level varian (cek varian terpilih atau pindah ke metafield produk), batasi loop metaobject dengan limit, pakai theme setting buat data statis, bandingin tanggal lewat date: '%s' | times: 1.
  4. Varian: jangan iterasi product.variants, pakai options_with_values + Section Rendering API dengan option_values, scope ke event.target.closest(). Combined listing: cabangkan lewat product_option_value.product_url.
  5. Paginasi: batas 25.000 (count 25.001), 12–50 per halaman, maksimal 250, dorong filter, infinite scroll pakai IntersectionObserver dan URL dari location.href biar filter ikut.
  6. render: tiap panggilan biaya tetap yang berlipat di loop, jangan include, inline markup sederhana, ratakan ke satu tingkat, skip_styles lewat variabel (argumen render gak bisa berisi perbandingan).
  7. Dialog/drawer: jaga konten mahal dengan parameter load_content, fetch section khusus waktu dibuka, recommendations lewat endpoint-nya sendiri, tetap limit.
  8. Filter sebelum loop dengan where/sort/map, sisakan satu cek di loop; filter di Liquid (Rust) lebih cepat dari banyak query database.
  9. Hoist assign, capture, image_url, asset_url, sort, money, size yang gak bergantung variabel loop.
  10. section.index (per lokasi, nil di statis/editor/API): tulis unless section.index > N biar nil jatuh ke eager, index == 1 + fetchpriority: 'high', section.location == 'header'.
  11. Section Rendering API: ≤5 section, ID dari section.id, konteks URL harus punya section itu, optimistic cart rendering.
  12. Mikro: cache filter dengan assign, block.settings/section.settings ke variabel, echo/{{ }} daripada append (hati-hati urutan filter dan money_without_currency di JSON), limit cukup buat collection.products/collections, paginate + limit buat array lain, jangan paginate daftar ukuran tetap.

Bab selanjutnya kita ke elemen yang biasanya jadi LCP: gambar. fetchpriority="high", jangan lazy-load gambar LCP, jangan sembunyiin di balik animasi, jangan pakai CSS background buat hero, srcset/sizes, dimensi buat nyegah CLS, dan picture buat hero yang beda di mobile.